diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..54a42d6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+dist/
+node_modules/
+.yarn/
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/sdks
+!.yarn/versions
+*.local
+.DS_Store
+coverage/
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..1d9b783
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+22.12.0
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..4754fa6
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,6 @@
+dist/
+node_modules/
+legacy/
+src/defaultStudioDocument.json
+src/standaloneEngine.generated.ts
+yarn.lock
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..fca7ef8
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,10 @@
+{
+ "trailingComma": "es5",
+ "tabWidth": 2,
+ "semi": false,
+ "singleQuote": true,
+ "jsxSingleQuote": false,
+ "bracketSpacing": true,
+ "arrowParens": "avoid",
+ "printWidth": 100
+}
diff --git a/.yarnrc.yml b/.yarnrc.yml
new file mode 100644
index 0000000..3186f3f
--- /dev/null
+++ b/.yarnrc.yml
@@ -0,0 +1 @@
+nodeLinker: node-modules
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..55dcece
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,34 @@
+# AGENTS.md
+
+## Project
+
+Bible Strong Avatar Lab is a standalone React 19 + Vite 8 application for procedural SVG avatars.
+Read `CONTEXT.md` before changing behavior or persistence semantics.
+
+## Commands
+
+```bash
+yarn dev
+yarn typecheck
+yarn test
+yarn build
+yarn check
+```
+
+## Engineering rules
+
+- Use TypeScript in strict mode.
+- Keep geometry, playback and document operations framework-independent.
+- Preserve the split between durable React state and high-frequency Motion values.
+- Do not use `useMemo`, `useCallback` or `memo`; React Compiler handles memoization.
+- Add or update focused tests for domain behavior.
+- Run `yarn typecheck` and the relevant test file while working, then `yarn check` before committing.
+- `src/standaloneEngine.generated.ts` is generated. Update it with `yarn engine`, never by hand.
+- Keep English, French and Simplified Chinese copy synchronized across `src/i18n.ts` and `src/i18n.zh.ts`.
+- Preserve project JSON compatibility only with the current pre-release schema unless a migration is explicitly requested.
+
+## UI conventions
+
+- Reuse components under `src/components/ui/` rather than adding one-off controls.
+- Keep selection, hover, focus and destructive actions visually consistent across avatars, expressions and animations.
+- Prefer transform/opacity animation over layout animation for continuous interactions.
diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 0000000..f8b0c29
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,68 @@
+# Avatar Lab Context
+
+## Purpose
+
+Bible Strong Avatar Lab is a browser-based authoring tool for procedural 2D avatars. It combines
+3D-inspired geometry with SVG rendering so creators can build a body from primitives, define a
+neutral face, author expressions and compose reusable animations. Generated packages do not depend
+on the Studio UI.
+
+## Ubiquitous language
+
+**Avatar**
+A reusable character whose body, colors and neutral eyes define its persistent visual identity.
+
+**Neutral appearance**
+The persistent visual base of an Avatar before an Expression is applied. Avoid “default pose”.
+
+**Pose**
+The Avatar's current temporary visual configuration. A Pose is not persisted directly.
+
+**Base behavior library**
+The immutable bundled collection of Expressions and Animations inherited by an Avatar that has not
+customized its behavior.
+
+**Avatar behavior library**
+The Avatar-owned collection of Expressions and Animations created atomically from the Base behavior
+library on that Avatar's first behavior mutation.
+
+**Expression**
+A saved visual preset within the Base behavior library or one Avatar behavior library. Eye values
+are relative to the owning Avatar's Neutral appearance. Optional color overrides are temporary.
+
+**Animation**
+A saved sequence of Expressions from the same behavior library, including transitions and blink
+behavior. Avoid “state” in user-facing copy.
+
+**Playback**
+The active execution of one Animation. It exclusively controls the animated Pose until direct user
+manipulation pauses it.
+
+## Invariants
+
+- An Avatar inherits the Base behavior library until its first Expression or Animation mutation.
+- The first behavior mutation copies Expressions and Animations together so references stay valid.
+- Subsequent behavior mutations affect only the owning Avatar.
+- Duplicating an Avatar duplicates its Avatar behavior library when one exists.
+- Transferring an Animation must also transfer every Expression it references.
+- The primary body shape carries the facial coordinate system and eyes.
+- Secondary primitives have independent local dimensions, position and rotation.
+- Expressions remain compatible across body surfaces because they operate in the common facial frame.
+- `geometry.ts` and the exported procedural engine stay independent from React.
+- `defaultStudioDocument.json` is the current schema baseline; pre-release legacy migrations are not required.
+- Live editing may update the preview, but unsaved avatar/expression edits must remain reversible.
+
+## Architecture boundaries
+
+- Pure geometry and document logic live in framework-independent TypeScript modules.
+- `App.tsx` coordinates UI and application state; avoid moving domain calculations into components.
+- Motion values own frame-by-frame visual updates; React state owns durable editor state.
+- Exports receive explicit avatar, expression and animation data rather than reading browser storage.
+- The generated standalone engine is committed and checked for freshness in local validation.
+
+## Current persistence
+
+The complete Studio document is persisted in browser local storage. JSON project export/import is the
+portable backup mechanism. There is no remote backend in this repository.
+
+Accepted architecture decisions live under `docs/adr/`.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b61f2e2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,51 @@
+# Bible Strong Avatar Lab
+
+Standalone React/Vite studio for creating procedural 2D avatars, expressions and animations.
+The project was extracted from Bible Strong so it can evolve independently.
+
+## Start
+
+Requirements: Node.js 22.12+ and Corepack/Yarn 4.
+
+```bash
+corepack enable
+yarn install
+yarn dev
+```
+
+Open `http://localhost:5173/`.
+
+## Validation
+
+```bash
+yarn typecheck
+yarn test
+yarn build
+```
+
+`yarn check` runs the generated-engine freshness check and the full validation suite.
+
+## Project map
+
+- `src/App.tsx`: Studio interface and orchestration.
+- `src/geometry.ts`: framework-independent procedural projection.
+- `src/surfaces.ts`: supported head surfaces and primitive definitions.
+- `src/renderedScene.ts`: stable render layer backed by Motion values.
+- `src/playback.ts`: expression and animation playback.
+- `src/studioDocument.ts`: versioned project document and browser persistence.
+- `src/exporter.ts`: React/TypeScript and standalone JavaScript exports.
+- `src/snapshotExporter.ts`: static SVG and PNG photo-mode exports.
+- `src/defaultStudioDocument.json`: bundled avatars, expressions and animations.
+- `scripts/generate-standalone-engine.mjs`: embeds the procedural runtime used by exports.
+- `legacy/`: the two self-contained HTML prototypes that preceded the React app.
+- `docs/adr/`: accepted architecture decisions inherited from the prototype.
+
+## Product model
+
+- An avatar owns its body and neutral eye appearance.
+- The bundled behavior library supplies the initial expressions and animations.
+- On the first behavior edit, an avatar receives its own independent copy of both.
+- An animation references expressions from the same behavior library.
+- The Studio document can be exported/imported as JSON for portability.
+
+See [CONTEXT.md](./CONTEXT.md) for the domain language and architecture constraints.
diff --git a/components.json b/components.json
new file mode 100644
index 0000000..b86ac10
--- /dev/null
+++ b/components.json
@@ -0,0 +1,22 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "base-nova",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/styles.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "registries": {}
+}
diff --git a/docs/adr/0022-model-avatar-studio-as-a-versioned-document.md b/docs/adr/0022-model-avatar-studio-as-a-versioned-document.md
new file mode 100644
index 0000000..a8df01e
--- /dev/null
+++ b/docs/adr/0022-model-avatar-studio-as-a-versioned-document.md
@@ -0,0 +1,26 @@
+# ADR-0022: Model Avatar Studio as a versioned document
+
+## Status
+
+Accepted
+
+## Context
+
+Avatar Studio previously persisted Avatars, Expressions, Animations, and Playback separately. Animation
+steps referenced Expressions by their current array position, so inserting or deleting an
+Expression required coordinated remapping and could leave partially persisted data.
+
+## Decision
+
+Avatar Studio persists one versioned Studio document. Every Expression has a durable identity and
+State steps reference that identity. Existing local data is migrated automatically by resolving
+legacy expression indexes against the migrated Expression catalog. An Avatar owns its Neutral
+appearance; global Expressions describe relative changes, and optional Expression colors remain
+temporary overrides. Playback exclusively owns the animated Pose until a direct user manipulation
+pauses it. Changing Avatar preserves the current Playback position.
+
+## Consequences
+
+Studio mutations preserve cross-object invariants in one transaction. Reordering Expressions no
+longer changes Animation meaning. The project is pre-release and the bundled version-2 document is
+the current baseline; no compatibility migration for earlier prototypes is maintained.
diff --git a/docs/adr/0023-name-avatar-sequences-animations.md b/docs/adr/0023-name-avatar-sequences-animations.md
new file mode 100644
index 0000000..0566ffc
--- /dev/null
+++ b/docs/adr/0023-name-avatar-sequences-animations.md
@@ -0,0 +1,26 @@
+# ADR-0023: Name Avatar Studio sequences Animations
+
+## Status
+
+Accepted
+
+## Context
+
+ADR-0022 introduced the technical `State` model for a persisted sequence of Expressions. In the
+Studio interface, “State” did not clearly communicate that the object is a complete, playable
+animation. “Sequence” was also used for the same object in editor copy, creating three competing
+names.
+
+## Decision
+
+The product term is **Animation**. An Animation contains ordered Expression steps, transitions,
+playback behavior and blink settings. **Playback** remains the running execution of an Animation.
+
+Existing internal identifiers such as `AvatarSequence`, `stateId` and legacy storage keys may remain
+until a dedicated migration is justified. They must not leak into user-facing copy or exported APIs.
+
+## Consequences
+
+Avatar Studio uses one consistent user-facing term in its tabs, editor, player and export flow.
+The current versioned document remains unchanged because this decision changes terminology rather
+than its internal shape.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..8c86814
--- /dev/null
+++ b/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Bible Strong Avatar Lab
+
+
+
+
+
+
diff --git a/legacy/index.html b/legacy/index.html
new file mode 100644
index 0000000..6cba140
--- /dev/null
+++ b/legacy/index.html
@@ -0,0 +1,4015 @@
+
+
+
+
+
+
+
+ Atelier GrokBot — prototype
+
+
+
+
+
+
+
+
+
+ Le visage combine trois systèmes indépendants : des contours SVG dessinés à l’avance, un
+ petit déplacement du regard, puis une projection sphérique utilisée pendant les rotations.
+ Les états choisissent et enchaînent ces briques.
+
+
+
+
+
+ Aperçu final expression 00
+
+
+ Avatar interactif
+
+ Le visage affiche l’expression, le regard et la rotation choisis dans les
+ contrôles.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ État idle
+ Forme blob
+ Expression 00
+
+
+ Cligner
+ Démontrer un tour
+ Réinitialiser
+
+
+
+
+
+
+ Laboratoire
+
+
+ États
+
+
+ Formes
+
+
+ Calculs & API
+
+
+
+
+
+
+
+
+
+
+
+
idle
+
+ Appliquer au labo
+
+
+
+
+
+
+ Changement d’expression
+
+
Clignement
+
+
+
+
+ Comment un état devient une animation
+
+
État idle
+
Pool [0, 8]
+
Cadence 9–16 s
+
Ressorts x, y, scale
+
SVG nouveau frame
+
+
+
+
+
+
+
+
+ Les 18 silhouettes et leur correction du visage
+
+ Chaque forme fournit un anneau de 96 points, une fonction donnant les limites
+ gauche/droite à une hauteur donnée, et cinq paramètres qui repositionnent le
+ visage.
+
+
+
+
+
+
+
+ Pipeline exact d’un frame
+
+
+
01 État + timers
+
expression, blink, morph
+
+
+
02 Ressorts
+
pas fixes à 120 Hz
+
+
+
03 Anneaux
+
interpolation des 48 points
+
+
+
04 Projection
+
position, profondeur, limites
+
+
+
05 SVG
+
d + transform à chaque frame
+
+
+
+
+
+ Formules principales
+
+
+
+
+ API publique observée
+
+
+ state="idle"Choisit les pools, cadences et mouvements secondaires.
+
+
+ shape="blob"Choisit la silhouette et la correction géométrique du
+ visage.
+
+
sizeFixe la taille rendue du SVG.
+
+ gazeTarget={x, y}Injecte une cible de regard en coordonnées écran.
+
+
+ mouseInteractiveÉcoute le pointeur global lorsque le composant est
+ visible.
+
+
+ flipXRetourne tout le SVG horizontalement.
+
+
+ emphasisForce une taille d’yeux et un corps légèrement plus grands.
+
+
+ spinSignalDéclenche une rotation via une valeur incrémentée.
+
+
+ badgeColorCouleur du badge utilisé par certains états.
+
+
badgeScaleÉchelle du badge d’état.
+
+ ref.spin(turns)Commande impérative de rotation.
+
+
+ ref.bounce() / ref.burst()Commandes impératives de rebond et
+ particules.
+
+
+
+
+
+
+
+ A · Split B · Dock C · Focus
+
+
+
+
+
+
+
+
diff --git a/legacy/spherical-eyes.html b/legacy/spherical-eyes.html
new file mode 100644
index 0000000..4a9805f
--- /dev/null
+++ b/legacy/spherical-eyes.html
@@ -0,0 +1,2894 @@
+
+
+
+
+
+ Yeux sphériques — prototype
+
+
+
+
+
+
+
+
+ Deux yeux capsules projetés sur une sphère
+
+ Les contours des yeux sont recalculés en direct selon la rotation de la tête et leurs
+ paramètres locaux.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ L
+ H
+ R
+ S
+ E
+
+
+
+ Glisse la boule pour tourner la tête. Clique un œil pour afficher ses poignées :
+ largeur, hauteur, rotation, taille globale et espacement. Les anneaux colorés règlent
+ X/Y/Z dans le coin supérieur droit ; l’anneau jaune tourne autour de la caméra.
+
+
+
+
+
+ Prototype 02 · géométrie procédurale
+ Deux yeux. Une seule forme.
+
+ Chaque œil est un rectangle long totalement arrondi. La taille et l’inclinaison sont
+ définies localement, puis la rotation de tête déforme réellement le contour sur une sphère
+ 3D avant de le ramener dans le SVG.
+
+
+
+
+ Manuel
+
+
+ Expressions
+
+
+ États
+
+
+
+
+
+
+
+ Expression active 00
+ Presets d’expressions
+
+ Chaque preset mémorise la tête, les deux yeux et la perspective. Clique pour
+ l’appliquer ou double-clique pour modifier tous ses paramètres.
+
+
+ Double-clic · modifier le preset
+
+
+
+
+
+
+
+ État actif idle
+ États animés
+
+ Un état choisit un pool d’expressions, une cadence de changement et une cadence de
+ clignement. Sélectionne un état puis lance-le pour observer la séquence.
+
+
+
+
+
+ idle
+
+ Pool d’expressions
+
+
+
+ Changement d’expression
+
+
+
+ Clignement
+
+
+
+
+ Expressions automatiques
+
+
+
+ Clignement automatique
+
+
+
+ Lancer cet état
+ Mettre en pause
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..e239415
--- /dev/null
+++ b/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "bible-strong-avatar-lab",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "packageManager": "yarn@4.12.0",
+ "engines": {
+ "node": ">=22.12.0"
+ },
+ "scripts": {
+ "dev": "yarn engine && vite",
+ "build": "yarn engine && vite build",
+ "preview": "vite preview",
+ "engine": "node scripts/generate-standalone-engine.mjs",
+ "engine:check": "node scripts/generate-standalone-engine.mjs --check",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "format": "prettier --write .",
+ "format:check": "prettier --check .",
+ "check": "yarn engine:check && yarn format:check && yarn typecheck && yarn test && yarn build"
+ },
+ "dependencies": {
+ "@base-ui/react": "^1.7.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^1.31.0",
+ "motion": "^13.0.0",
+ "react": "19.2.3",
+ "react-dom": "19.2.3",
+ "tailwind-merge": "^3.6.0"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.29.0",
+ "@babel/parser": "^7.28.6",
+ "@rolldown/plugin-babel": "^0.2.3",
+ "@tailwindcss/vite": "^4.3.3",
+ "@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",
+ "prettier": "^3.8.1",
+ "rolldown": "^1.0.0-rc.5",
+ "tailwindcss": "^4.3.3",
+ "typescript": "~6.0.3",
+ "vite": "^8.0.13",
+ "vitest": "^4.1.10"
+ }
+}
diff --git a/scripts/generate-standalone-engine.mjs b/scripts/generate-standalone-engine.mjs
new file mode 100644
index 0000000..34274cc
--- /dev/null
+++ b/scripts/generate-standalone-engine.mjs
@@ -0,0 +1,33 @@
+import { readFile, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+import { build } from 'vite'
+
+const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
+const result = await build({
+ configFile: false,
+ logLevel: 'silent',
+ build: {
+ write: false,
+ minify: true,
+ lib: {
+ entry: path.join(root, 'src/standaloneEngine.entry.ts'),
+ formats: ['iife'],
+ name: 'AvatarProceduralEngine',
+ },
+ },
+})
+const outputs = Array.isArray(result) ? result.flatMap(item => item.output) : result.output
+const chunk = outputs.find(item => item.type === 'chunk')
+if (!chunk) throw new Error('Standalone avatar engine bundle was not generated.')
+
+const outputPath = path.join(root, 'src/standaloneEngine.generated.ts')
+const source = `// Generated by scripts/generate-standalone-engine.mjs. Do not edit.\nexport const standaloneEngineSource = ${JSON.stringify(chunk.code)}\n`
+
+if (process.argv.includes('--check')) {
+ const current = await readFile(outputPath, 'utf8').catch(() => '')
+ if (current !== source) throw new Error('Standalone avatar engine is stale. Run yarn engine.')
+} else {
+ await writeFile(outputPath, source)
+}
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..75e9096
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,6015 @@
+import {
+ animate,
+ motion,
+ useMotionValue,
+ useMotionValueEvent,
+ useReducedMotion,
+} from 'motion/react'
+import {
+ useEffect,
+ useEffectEvent,
+ useId,
+ useRef,
+ useState,
+ type CSSProperties,
+ type RefObject,
+} from 'react'
+import {
+ ArrowLeft,
+ Camera,
+ ChevronDown,
+ ChevronUp,
+ Copy,
+ Download,
+ FileCode2,
+ GripVertical,
+ Info,
+ Pause,
+ Pencil,
+ Play,
+ Plus,
+ RotateCcw,
+ Square,
+ Trash2,
+ Upload,
+} from 'lucide-react'
+
+import { Button } from './components/ui/button'
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from './components/ui/accordion'
+import { Badge } from './components/ui/badge'
+import { Card, CardDescription, CardHeader, CardTitle } from './components/ui/card'
+import { Field, FieldTitle } from './components/ui/field'
+import { Input } from './components/ui/input'
+import { Separator } from './components/ui/separator'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from './components/ui/select'
+import { Switch } from './components/ui/switch'
+import { Tabs, TabsList, TabsTrigger } from './components/ui/tabs'
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './components/ui/tooltip'
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from './components/ui/context-menu'
+import { StudioLanguageProvider, useStudioLanguage, type StudioLanguage } from './i18n'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from './components/ui/alert-dialog'
+
+import {
+ bodyPrimitiveTypes,
+ createBodyNode,
+ duplicateBodyNode,
+ MAX_BODY_NODES,
+ type BodyNode,
+} from './body'
+import {
+ applyAvatarEyeDefaults,
+ cloneAvatarBehavior,
+ createAvatar,
+ defaultAvatarEyes,
+ resolveAvatarBehavior,
+ type AvatarBehaviorLibrary,
+ type StudioAvatar,
+ type AvatarColors,
+ type AvatarEyeDefaults,
+} from './avatars'
+import {
+ expressionFields,
+ poseFromExpression,
+ renderAvatar,
+ renderBodyNodeEditor,
+ renderEyeEditor,
+ rotateBodyNodeAroundLocalAxis,
+ rotateExpressionAroundAxis,
+ rotateExpressionAroundCamera,
+ rotateExpressionWithArcball,
+ rotationRing,
+ translateBodyNodeInCameraPlane,
+ translateBodyNodeAlongLocalAxis,
+ type AvatarPose,
+ type Expression,
+ type Point3,
+} from './geometry'
+import { defaultExpression } from './presets'
+import {
+ createSequence,
+ createSequenceStep,
+ duplicateSequence,
+ findExpressionIndex,
+ getSequenceSpring,
+ groupSequences,
+ readSequenceClock,
+ remapSequencesAfterExpressionDelete,
+ type AvatarSequence,
+ type SequenceStep,
+} from './sequences'
+import { ambientBodyOffset, applyAmbientMotion, hasAmbientMotion } from './ambientMotion'
+import { surfaceLabels, surfacePresets, type SurfaceConfig } from './surfaces'
+import {
+ createStudioDocumentStore,
+ loadStudioDocument,
+ parseImportedStudioDocument,
+ persistStudioDocument,
+ serializeStudioDocument,
+ type StudioDocument,
+ type StatePlaybackSelection,
+} from './studioDocument'
+import {
+ advancePlaybackTimeline,
+ beginPlayback,
+ createPlaybackTimeline,
+ pausePlaybackTimeline,
+ schedulePlaybackBlink,
+ schedulePlaybackStep,
+ stopPlaybackTimeline,
+} from './playback'
+import {
+ createRenderedColors,
+ createRenderedScene,
+ findBodyNodePath,
+ paintRenderedColors,
+ paintRenderedOffset,
+ paintRenderedScene,
+ type RenderedScene,
+ type RenderedColors,
+} from './renderedScene'
+import {
+ createRenderedRotationGizmo,
+ paintRenderedRotationGizmo,
+ type RenderedRotationGizmo,
+} from './renderedRotationGizmo'
+import { scaleEye, updateEyeDimension, updateEyePosition } from './expressionEditing'
+import { resolveCanvasPreviewExpression, type CanvasPreviewTarget } from './canvasPreview'
+import {
+ beginManipulation,
+ beginManipulationFromRenderedValue,
+ finishManipulation,
+ previewManipulation,
+ type ManipulationSession,
+} from './manipulationSession'
+import {
+ avatarExportFileName,
+ createAvatarExportPayload,
+ generateJavaScriptAvatarPackage,
+ generateReactAvatarPackage,
+} from './exporter'
+import {
+ serializeAvatarSnapshot,
+ snapshotFileName,
+ type SnapshotBackground,
+} from './snapshotExporter'
+
+type Mode = 'manual' | 'expressions' | 'states' | 'export'
+type ExportFormat = 'react' | 'javascript'
+type SnapshotFormat = 'svg' | 'png'
+type PlaybackStatus = 'playing' | 'paused' | 'stopped'
+
+const downloadBlob = (blob: Blob, fileName: string) => {
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = fileName
+ document.body.append(link)
+ link.click()
+ link.remove()
+ window.setTimeout(() => URL.revokeObjectURL(url), 0)
+}
+type Side = 'Left' | 'Right'
+type NumericProps = {
+ label: string
+ value: number
+ min?: number
+ max?: number
+ step?: number
+ unit?: string
+ onChange: (value: number) => void
+ onActiveChange?: (active: boolean) => void
+}
+type Highlight = 'head' | 'left' | 'right' | 'both' | null
+
+const RETARGET_BLEND_MS = 120
+const INSPECTOR_FRAME_MS = 1000 / 24
+const AMBIENT_FRAME_MS = 1000 / 30
+const createExpressionId = () => `expression-${crypto.randomUUID()}`
+const emptyBodyNodes: BodyNode[] = []
+const previewGeometryCache = new WeakMap<
+ Expression,
+ WeakMap<
+ SurfaceConfig,
+ WeakMap }>
+ >
+>()
+
+const poseWithAvatarEyes = (expression: Expression, eyes: AvatarEyeDefaults) =>
+ poseFromExpression(applyAvatarEyeDefaults(expression, eyes))
+
+const getPreviewGeometry = (
+ expression: Expression,
+ surface: SurfaceConfig,
+ bodyNodes: BodyNode[],
+ eyes: AvatarEyeDefaults = defaultAvatarEyes
+) => {
+ let surfaceCache = previewGeometryCache.get(expression)
+ if (!surfaceCache) {
+ surfaceCache = new WeakMap()
+ previewGeometryCache.set(expression, surfaceCache)
+ }
+ let bodyCache = surfaceCache.get(surface)
+ if (!bodyCache) {
+ bodyCache = new WeakMap()
+ surfaceCache.set(surface, bodyCache)
+ }
+ const positionKey = JSON.stringify(eyes)
+ const cached = bodyCache.get(bodyNodes)
+ if (cached?.positionKey === positionKey) return cached.geometry
+ const geometry = renderAvatar(poseWithAvatarEyes(expression, eyes), surface, 1, {
+ includeWire: false,
+ bodyNodes,
+ })
+ bodyCache.set(bodyNodes, { positionKey, geometry })
+ return geometry
+}
+
+const bounded = (value: number, min?: number, max?: number) =>
+ Math.min(max ?? Infinity, Math.max(min ?? -Infinity, value))
+
+const useEscapeToCancel = (cancel: () => void) => {
+ const cancelEvent = useEffectEvent(cancel)
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') cancelEvent()
+ }
+ window.addEventListener('keydown', onKeyDown)
+ return () => window.removeEventListener('keydown', onKeyDown)
+ }, [])
+}
+
+const scaleSurface = (
+ surface: SurfaceConfig,
+ size: number,
+ minimums: Pick
+) => {
+ const currentSize = Math.max(surface.width, surface.height, surface.depth) || 1
+ const factor = size / currentSize
+ return {
+ ...surface,
+ width: bounded(surface.width * factor, minimums.width, 300),
+ height: bounded(surface.height * factor, minimums.height, 300),
+ depth: bounded(surface.depth * factor, minimums.depth, 300),
+ }
+}
+
+const formatSeconds = (milliseconds: number, language: StudioLanguage) =>
+ `${new Intl.NumberFormat(language === 'fr' ? 'fr-FR' : language === 'zh-CN' ? 'zh-CN' : 'en-US', {
+ maximumFractionDigits: 1,
+ }).format(milliseconds / 1000)} s`
+
+const parseHexColor = (color: string) => {
+ const value = color.replace('#', '')
+ return [0, 2, 4].map(offset => Number.parseInt(value.slice(offset, offset + 2), 16))
+}
+
+const interpolateHexColor = (from: string, to: string, progress: number) => {
+ const fromChannels = parseHexColor(from)
+ const toChannels = parseHexColor(to)
+ return `#${fromChannels
+ .map((channel, index) =>
+ Math.round(channel + (toChannels[index] - channel) * progress)
+ .toString(16)
+ .padStart(2, '0')
+ )
+ .join('')}`
+}
+
+const resolveColors = (expression: Expression, colors: AvatarColors): AvatarColors => ({
+ body: expression.bodyColor ?? colors.body,
+ eyes: expression.eyeColor ?? colors.eyes,
+})
+
+function ColorField({
+ label,
+ value,
+ onChange,
+}: {
+ label: string
+ value: string
+ onChange: (value: string) => void
+}) {
+ const { t } = useStudioLanguage()
+ const translatedLabel = t(label)
+ return (
+
+ {translatedLabel}
+
+ onChange(event.currentTarget.value)}
+ />
+ {
+ const next = event.currentTarget.value.toUpperCase()
+ if (/^#[0-9A-F]{6}$/.test(next)) onChange(next.toLowerCase())
+ }}
+ onBlur={event => {
+ if (!/^#[0-9A-F]{6}$/.test(event.currentTarget.value.toUpperCase())) {
+ event.currentTarget.value = value.toUpperCase()
+ }
+ }}
+ />
+
+
+ )
+}
+
+function AmbientMotionField({
+ label,
+ value,
+ options,
+ onChange,
+}: {
+ label: string
+ value: Motion
+ options: { value: Motion; label: string }[]
+ onChange: (value: Motion) => void
+}) {
+ const { t } = useStudioLanguage()
+ return (
+
+ {t(label)}
+ ({ value: option.value, label: t(option.label) }))}
+ onValueChange={next => next && onChange(next as Motion)}
+ >
+
+
+
+
+ {options.map(option => (
+
+ {t(option.label)}
+
+ ))}
+
+
+
+ )
+}
+
+function NumericField({
+ label,
+ value,
+ min,
+ max,
+ step = 0.1,
+ unit = '',
+ onChange,
+ onActiveChange,
+}: NumericProps) {
+ const { t } = useStudioLanguage()
+ const translatedLabel = t(label)
+ const dragRef = useRef<{ x: number; value: number } | null>(null)
+ const editingRef = useRef(false)
+ const [draftValue, setDraftValue] = useState(() =>
+ String(Number(value.toFixed(step < 0.1 ? 2 : 1)))
+ )
+
+ useEffect(() => {
+ if (!editingRef.current) {
+ setDraftValue(String(Number(value.toFixed(step < 0.1 ? 2 : 1))))
+ }
+ }, [step, value])
+
+ const commitDraftValue = (rawValue: string) => {
+ const parsedValue = Number(rawValue.replace(',', '.'))
+ const nextValue =
+ Number.isFinite(parsedValue) && rawValue.trim() !== ''
+ ? bounded(parsedValue, min, max)
+ : value
+
+ setDraftValue(String(Number(nextValue.toFixed(step < 0.1 ? 2 : 1))))
+ onChange(nextValue)
+ }
+
+ const startScrub = (event: React.PointerEvent) => {
+ event.preventDefault()
+ onActiveChange?.(true)
+ dragRef.current = { x: event.clientX, value }
+ event.currentTarget.setPointerCapture(event.pointerId)
+ }
+
+ const scrub = (event: React.PointerEvent) => {
+ if (!dragRef.current || !event.currentTarget.hasPointerCapture(event.pointerId)) return
+ const speed = event.shiftKey ? 10 : event.altKey ? 0.1 : 1
+ const next = dragRef.current.value + (event.clientX - dragRef.current.x) * step * speed
+ onChange(bounded(next, min, max))
+ }
+
+ const stopScrub = () => {
+ dragRef.current = null
+ onActiveChange?.(false)
+ }
+
+ return (
+
+
+ {translatedLabel}
+
+ ↔
+
+
+
+ {
+ editingRef.current = true
+ onActiveChange?.(true)
+ }}
+ onBlur={event => {
+ editingRef.current = false
+ commitDraftValue(event.currentTarget.value)
+ onActiveChange?.(false)
+ }}
+ onChange={event => setDraftValue(event.currentTarget.value)}
+ onKeyDown={event => {
+ if (event.key === 'Enter') event.currentTarget.blur()
+ }}
+ />
+ {unit}
+
+
+ )
+}
+
+function LinkButton({
+ linked,
+ onClick,
+ label,
+}: {
+ linked: boolean
+ onClick: () => void
+ label: string
+}) {
+ const { t } = useStudioLanguage()
+ return (
+
+
+
+
+
+
+ )
+}
+
+function RotationGizmo({
+ expression,
+ rendered,
+ onChange,
+ onActiveChange,
+ onReset,
+}: {
+ expression: Expression
+ rendered: RenderedRotationGizmo
+ onChange: (next: Expression) => void
+ onActiveChange: (active: boolean) => void
+ onReset: () => void
+}) {
+ const { t } = useStudioLanguage()
+ const drag = useRef<
+ | {
+ type: 'axis'
+ axis: 'x' | 'y' | 'z'
+ startPoint: readonly [number, number]
+ tangent: readonly [number, number]
+ expression: Expression
+ }
+ | { type: 'view'; startAngle: number; expression: Expression }
+ | null
+ >(null)
+ const pose = poseFromExpression(expression)
+ const rings = {
+ x: rotationRing(pose, 'x'),
+ y: rotationRing(pose, 'y'),
+ z: rotationRing(pose, 'z'),
+ }
+ const toLocal = (event: React.PointerEvent): readonly [number, number] => {
+ const rectangle = event.currentTarget.ownerSVGElement!.getBoundingClientRect()
+ return [
+ ((event.clientX - rectangle.left) / rectangle.width) * 86 - 43,
+ ((event.clientY - rectangle.top) / rectangle.height) * 86 - 43,
+ ]
+ }
+ const unitVector = (from: Point3, to: Point3): readonly [number, number] => {
+ const x = to[0] - from[0]
+ const y = to[1] - from[1]
+ const length = Math.hypot(x, y) || 1
+ return [x / length, y / length]
+ }
+ const startAxis = (axis: 'x' | 'y' | 'z', event: React.PointerEvent) => {
+ event.stopPropagation()
+ onActiveChange(true)
+ const point = toLocal(event)
+ const ring = rings[axis]
+ let closestIndex = 0
+ let closestDistance = Number.POSITIVE_INFINITY
+ ring.slice(0, -1).forEach((ringPoint, index) => {
+ const distance = Math.hypot(ringPoint[0] - point[0], ringPoint[1] - point[1])
+ if (distance < closestDistance) {
+ closestIndex = index
+ closestDistance = distance
+ }
+ })
+ const previous = ring[(closestIndex - 1 + ring.length - 1) % (ring.length - 1)]
+ const next = ring[(closestIndex + 1) % (ring.length - 1)]
+ drag.current = {
+ type: 'axis',
+ axis,
+ startPoint: point,
+ tangent: unitVector(previous, next),
+ expression,
+ }
+ event.currentTarget.setPointerCapture(event.pointerId)
+ }
+ const startView = (event: React.PointerEvent) => {
+ event.stopPropagation()
+ onActiveChange(true)
+ const point = toLocal(event)
+ drag.current = { type: 'view', startAngle: Math.atan2(point[1], point[0]), expression }
+ event.currentTarget.setPointerCapture(event.pointerId)
+ }
+ const move = (event: React.PointerEvent) => {
+ if (!drag.current) return
+ const point = toLocal(event)
+ if (drag.current.type === 'view') {
+ const currentAngle = Math.atan2(point[1], point[0])
+ const delta = Math.atan2(
+ Math.sin(currentAngle - drag.current.startAngle),
+ Math.cos(currentAngle - drag.current.startAngle)
+ )
+ onChange(rotateExpressionAroundCamera(drag.current.expression, delta))
+ return
+ }
+ const signedDistance =
+ (point[0] - drag.current.startPoint[0]) * drag.current.tangent[0] +
+ (point[1] - drag.current.startPoint[1]) * drag.current.tangent[1]
+ onChange(
+ rotateExpressionAroundAxis(drag.current.expression, drag.current.axis, signedDistance * 1.5)
+ )
+ }
+ const stop = () => {
+ drag.current = null
+ onActiveChange(false)
+ }
+ const cancel = () => {
+ if (drag.current) onChange(drag.current.expression)
+ stop()
+ }
+ useEscapeToCancel(cancel)
+ return (
+
+
+
+ startAxis('y', event)}
+ onPointerMove={move}
+ onPointerUp={stop}
+ onPointerCancel={cancel}
+ />
+ startAxis('x', event)}
+ onPointerMove={move}
+ onPointerUp={stop}
+ onPointerCancel={cancel}
+ />
+ startAxis('z', event)}
+ onPointerMove={move}
+ onPointerUp={stop}
+ onPointerCancel={cancel}
+ />
+
+
+
+
+
+ )
+}
+
+type TransformAxis = 'x' | 'y' | 'z'
+
+function BodyNodeGizmo({
+ svgRef,
+ pose,
+ node,
+ onPreview,
+ onCommit,
+}: {
+ svgRef: React.RefObject
+ pose: AvatarPose
+ node: BodyNode
+ onPreview: (next: BodyNode) => void
+ onCommit: (next: BodyNode) => void
+}) {
+ const { t } = useStudioLanguage()
+ const geometry = renderBodyNodeEditor(pose, node)
+ const [activeControl, setActiveControl] = useState<
+ { mode: 'translate' | 'rotate'; axis: TransformAxis } | { mode: 'plane' } | undefined
+ >(undefined)
+ const drag = useRef<
+ | {
+ mode: 'translate' | 'rotate'
+ axis: TransformAxis
+ startPoint: readonly [number, number]
+ direction: readonly [number, number]
+ scale: number
+ node: BodyNode
+ }
+ | {
+ mode: 'plane'
+ startPoint: readonly [number, number]
+ node: BodyNode
+ }
+ | undefined
+ >(undefined)
+ const latestNode = useRef(node)
+ const manipulation = useRef | null>(null)
+ const previewFrame = useRef(undefined)
+ const axes: TransformAxis[] = ['x', 'y', 'z']
+ const toSvg = (event: React.PointerEvent): readonly [number, number] => {
+ const rectangle = svgRef.current!.getBoundingClientRect()
+ return [
+ ((event.clientX - rectangle.left) / rectangle.width) * 300 - 150,
+ ((event.clientY - rectangle.top) / rectangle.height) * 300 - 150,
+ ]
+ }
+ const directionBetween = (from: Point3, to: Point3): readonly [number, number] => {
+ const x = to[0] - from[0]
+ const y = to[1] - from[1]
+ const length = Math.hypot(x, y) || 1
+ return [x / length, y / length]
+ }
+ const ringPath = (points: Point3[]) =>
+ `M${points.map(point => `${point[0]} ${point[1]}`).join('L')}Z`
+ const startTranslate = (axis: TransformAxis, event: React.PointerEvent) => {
+ event.stopPropagation()
+ const endpoint = geometry.axes[axis]
+ const length = Math.max(
+ Math.hypot(endpoint[0] - geometry.center[0], endpoint[1] - geometry.center[1]),
+ 1
+ )
+ drag.current = {
+ mode: 'translate',
+ axis,
+ startPoint: toSvg(event),
+ direction: directionBetween(geometry.center, endpoint),
+ scale: 34 / length,
+ node,
+ }
+ setActiveControl({ mode: 'translate', axis })
+ latestNode.current = node
+ manipulation.current = beginManipulation(node)
+ event.currentTarget.setPointerCapture(event.pointerId)
+ }
+ const startRotate = (axis: TransformAxis, event: React.PointerEvent) => {
+ event.stopPropagation()
+ const point = toSvg(event)
+ const ring = geometry.rings[axis]
+ let closestIndex = 0
+ let closestDistance = Infinity
+ ring.slice(0, -1).forEach((ringPoint, index) => {
+ const distance = Math.hypot(ringPoint[0] - point[0], ringPoint[1] - point[1])
+ if (distance < closestDistance) {
+ closestIndex = index
+ closestDistance = distance
+ }
+ })
+ const previous = ring[(closestIndex - 1 + ring.length - 1) % (ring.length - 1)]
+ const next = ring[(closestIndex + 1) % (ring.length - 1)]
+ const radius = Math.max(
+ Math.hypot(
+ ring[closestIndex][0] - geometry.center[0],
+ ring[closestIndex][1] - geometry.center[1]
+ ),
+ 8
+ )
+ drag.current = {
+ mode: 'rotate',
+ axis,
+ startPoint: point,
+ direction: directionBetween(previous, next),
+ scale: 180 / Math.PI / radius,
+ node,
+ }
+ setActiveControl({ mode: 'rotate', axis })
+ latestNode.current = node
+ manipulation.current = beginManipulation(node)
+ event.currentTarget.setPointerCapture(event.pointerId)
+ }
+ const startPlaneTranslate = (event: React.PointerEvent) => {
+ event.stopPropagation()
+ drag.current = { mode: 'plane', startPoint: toSvg(event), node }
+ setActiveControl({ mode: 'plane' })
+ latestNode.current = node
+ manipulation.current = beginManipulation(node)
+ event.currentTarget.setPointerCapture(event.pointerId)
+ }
+ const move = (event: React.PointerEvent) => {
+ if (!drag.current) return
+ const interaction = drag.current
+ const point = toSvg(event)
+ const deltaX = point[0] - interaction.startPoint[0]
+ const deltaY = point[1] - interaction.startPoint[1]
+ const next = (() => {
+ if (interaction.mode === 'plane') {
+ return translateBodyNodeInCameraPlane(interaction.node, pose, deltaX, deltaY)
+ }
+ const delta =
+ (deltaX * interaction.direction[0] + deltaY * interaction.direction[1]) * interaction.scale
+ return interaction.mode === 'translate'
+ ? translateBodyNodeAlongLocalAxis(interaction.node, interaction.axis, delta)
+ : rotateBodyNodeAroundLocalAxis(interaction.node, interaction.axis, delta)
+ })()
+ latestNode.current = next
+ if (manipulation.current) {
+ previewManipulation(manipulation.current, next)
+ }
+ if (previewFrame.current !== undefined) return
+ previewFrame.current = requestAnimationFrame(() => {
+ previewFrame.current = undefined
+ onPreview(latestNode.current)
+ })
+ }
+ const stop = () => {
+ if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current)
+ previewFrame.current = undefined
+ if (manipulation.current) {
+ finishManipulation(manipulation.current, 'commit', { preview: onPreview, commit: onCommit })
+ }
+ manipulation.current = null
+ drag.current = undefined
+ setActiveControl(undefined)
+ }
+ const cancel = () => {
+ if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current)
+ previewFrame.current = undefined
+ if (manipulation.current) {
+ finishManipulation(manipulation.current, 'cancel', { preview: onPreview, commit: onCommit })
+ }
+ manipulation.current = null
+ drag.current = undefined
+ setActiveControl(undefined)
+ }
+ useEscapeToCancel(cancel)
+ const resetTransform = (event: React.MouseEvent) => {
+ event.stopPropagation()
+ const resetNode: BodyNode = { ...node, position: [0, 0, 0], rotation: [0, 0, 0] }
+ latestNode.current = resetNode
+ onCommit(resetNode)
+ }
+
+ useEffect(
+ () => () => {
+ if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current)
+ },
+ []
+ )
+
+ return (
+
+ {axes.map(axis => (
+
+ startRotate(axis, event)}
+ onPointerMove={move}
+ onPointerUp={stop}
+ onPointerCancel={cancel}
+ />
+
+
+ ))}
+ {axes.map(axis => (
+
+ startTranslate(axis, event)}
+ onPointerMove={move}
+ onPointerUp={stop}
+ onPointerCancel={cancel}
+ />
+
+
+
+ {axis.toUpperCase()}
+
+
+ ))}
+
+
+ {
+ if (event.key !== 'Enter' && event.key !== ' ') return
+ event.preventDefault()
+ const resetNode: BodyNode = { ...node, position: [0, 0, 0], rotation: [0, 0, 0] }
+ latestNode.current = resetNode
+ onCommit(resetNode)
+ }}
+ >
+
+ ↺
+
+
+ )
+}
+
+function AvatarCanvas({
+ expression,
+ avatarEyes,
+ surface,
+ scene,
+ rotationGizmo,
+ showWire,
+ bodyEditing,
+ selectedBodyNodeId,
+ selectedBodyNode,
+ selectedSide,
+ linked,
+ highlight,
+ onHighlightChange,
+ onBodyNodeSelect,
+ onBodyNodePreview,
+ onBodyNodeChange,
+ onEyeSelect,
+ onPreview,
+ onChange,
+ onReset,
+ onEyeChange,
+ playback,
+ onManipulationStart,
+}: {
+ expression: Expression
+ avatarEyes: AvatarEyeDefaults
+ surface: SurfaceConfig
+ scene: RenderedScene
+ rotationGizmo: RenderedRotationGizmo
+ showWire: boolean
+ bodyEditing: boolean
+ selectedBodyNodeId: 'primary' | string | null
+ selectedBodyNode: BodyNode | null
+ selectedSide: -1 | 1 | null
+ linked: { width: boolean; height: boolean; size: boolean }
+ highlight: Highlight
+ onHighlightChange: (highlight: Highlight) => void
+ onBodyNodeSelect: (id: 'primary' | string | null) => void
+ onBodyNodePreview: (next: BodyNode) => void
+ onBodyNodeChange: (next: BodyNode) => void
+ onEyeSelect: (side: -1 | 1) => void
+ onPreview: (next: Expression, target: CanvasPreviewTarget) => void
+ onChange: (next: Expression) => void
+ onReset: (next: Expression) => void
+ onEyeChange?: (next: Expression) => void
+ playback: { name: string; status: Exclude } | null
+ onManipulationStart: () => Expression
+}) {
+ const { t } = useStudioLanguage()
+ const {
+ wirePaths,
+ backPaths,
+ frontPaths,
+ backNodeIds,
+ frontNodeIds,
+ headPath,
+ leftPath,
+ rightPath,
+ leftOpacity,
+ rightOpacity,
+ offsetX,
+ offsetY,
+ } = scene
+ const svgRef = useRef(null)
+ const [activeDragType, setActiveDragType] = useState<
+ 'arcball' | 'width' | 'height' | 'size' | 'spacing' | 'rotate' | null
+ >(null)
+ const drag = useRef<
+ | {
+ type: 'arcball'
+ startPoint: readonly [number, number]
+ expression: Expression
+ }
+ | {
+ type: 'width' | 'height' | 'size' | 'spacing' | 'rotate'
+ side: -1 | 1
+ startPoint: readonly [number, number]
+ expression: Expression
+ center: Point3
+ widthAxis: readonly [number, number]
+ heightAxis: readonly [number, number]
+ spacingAxis: readonly [number, number]
+ startPointerAngle: number
+ startDistance: number
+ }
+ | null
+ >(null)
+ const canvasManipulation = useRef | null>(null)
+ const editor =
+ selectedSide === null
+ ? null
+ : renderEyeEditor(poseWithAvatarEyes(expression, avatarEyes), surface, selectedSide)
+ const selectedBodyPath = (() => {
+ if (!bodyEditing || !selectedBodyNodeId) return null
+ return findBodyNodePath(scene, selectedBodyNodeId)
+ })()
+
+ const toSvg = (event: React.PointerEvent): readonly [number, number] => {
+ const rectangle = svgRef.current!.getBoundingClientRect()
+ return [
+ ((event.clientX - rectangle.left) / rectangle.width) * 300 - 150,
+ ((event.clientY - rectangle.top) / rectangle.height) * 300 - 150,
+ ]
+ }
+ const unitVector = (from: Point3, to: Point3): readonly [number, number] => {
+ const x = to[0] - from[0]
+ const y = to[1] - from[1]
+ const length = Math.hypot(x, y) || 1
+ return [x / length, y / length]
+ }
+ const startDrag = (event: React.PointerEvent) => {
+ const session = beginManipulationFromRenderedValue(onManipulationStart)
+ const renderedExpression = session.initial
+ onBodyNodeSelect('primary')
+ onHighlightChange('head')
+ drag.current = {
+ type: 'arcball',
+ startPoint: toSvg(event),
+ expression: renderedExpression,
+ }
+ canvasManipulation.current = session
+ setActiveDragType('arcball')
+ svgRef.current!.setPointerCapture(event.pointerId)
+ }
+ const selectBodyPath = (
+ event: React.PointerEvent,
+ nodeId: string | null | undefined
+ ) => {
+ if (!nodeId || !bodyEditing) {
+ startDrag(event)
+ return
+ }
+ event.stopPropagation()
+ onBodyNodeSelect(nodeId)
+ }
+ const selectEye = (side: -1 | 1, event: React.PointerEvent) => {
+ event.stopPropagation()
+ onBodyNodeSelect(null)
+ onEyeSelect(side)
+ }
+ const startHandle = (
+ type: 'width' | 'height' | 'size' | 'spacing' | 'rotate',
+ event: React.PointerEvent
+ ) => {
+ event.stopPropagation()
+ if (selectedSide === null || !editor) return
+ const session = beginManipulationFromRenderedValue(onManipulationStart)
+ const renderedExpression = session.initial
+ onHighlightChange(selectedSide < 0 ? 'left' : 'right')
+ const point = toSvg(event)
+ const editableExpression = bodyEditing
+ ? applyAvatarEyeDefaults(renderedExpression, avatarEyes)
+ : renderedExpression
+ const livePose = poseWithAvatarEyes(renderedExpression, avatarEyes)
+ const liveEditor = renderEyeEditor(livePose, surface, selectedSide)
+ const leftEditor = renderEyeEditor(livePose, surface, -1)
+ const rightEditor = renderEyeEditor(livePose, surface, 1)
+ drag.current = {
+ type,
+ side: selectedSide,
+ startPoint: point,
+ expression: editableExpression,
+ center: liveEditor.center,
+ widthAxis: unitVector(liveEditor.center, liveEditor.widthHandle),
+ heightAxis: unitVector(liveEditor.center, liveEditor.heightHandle),
+ spacingAxis: unitVector(leftEditor.center, rightEditor.center),
+ startPointerAngle: Math.atan2(
+ point[1] - liveEditor.center[1],
+ point[0] - liveEditor.center[0]
+ ),
+ startDistance: Math.max(
+ Math.hypot(point[0] - liveEditor.center[0], point[1] - liveEditor.center[1]),
+ 1
+ ),
+ }
+ canvasManipulation.current = beginManipulation(editableExpression)
+ setActiveDragType(type)
+ svgRef.current!.setPointerCapture(event.pointerId)
+ }
+ const move = (event: React.PointerEvent) => {
+ if (!drag.current) return
+ const point = toSvg(event)
+ if (drag.current.type === 'arcball') {
+ const next = rotateExpressionWithArcball(
+ drag.current.expression,
+ drag.current.startPoint,
+ point
+ )
+ if (canvasManipulation.current) {
+ previewManipulation(canvasManipulation.current, next, value => onPreview(value, 'head'))
+ }
+ return
+ }
+ const interaction = drag.current
+ const suffix = interaction.side < 0 ? 'Left' : 'Right'
+ const deltaX = point[0] - interaction.startPoint[0]
+ const deltaY = point[1] - interaction.startPoint[1]
+ const along = (axis: readonly [number, number]) => deltaX * axis[0] + deltaY * axis[1]
+ let next = { ...interaction.expression }
+ const side = interaction.side < 0 ? 'Left' : 'Right'
+ if (interaction.type === 'width') {
+ const value = bounded(
+ interaction.expression[`width${suffix}`] + along(interaction.widthAxis) * 2,
+ 10,
+ 100
+ )
+ next = updateEyeDimension(interaction.expression, side, 'width', value, linked.width)
+ } else if (interaction.type === 'height') {
+ const value = bounded(
+ interaction.expression[`height${suffix}`] + along(interaction.heightAxis) * 2,
+ 10,
+ 100
+ )
+ next = updateEyeDimension(interaction.expression, side, 'height', value, linked.height)
+ } else if (interaction.type === 'size') {
+ const distance = Math.hypot(
+ point[0] - interaction.center[0],
+ point[1] - interaction.center[1]
+ )
+ const factor = distance / interaction.startDistance
+ const targetSize =
+ Math.max(
+ interaction.expression[`width${suffix}`],
+ interaction.expression[`height${suffix}`]
+ ) * factor
+ next = scaleEye(interaction.expression, side, targetSize, linked.size)
+ } else if (interaction.type === 'spacing') {
+ const startSpacing = interaction.expression.spacing
+ const spacing = bounded(startSpacing + along(interaction.spacingAxis), 0, 150)
+ next.spacing = spacing
+ } else {
+ const currentAngle = Math.atan2(
+ point[1] - interaction.center[1],
+ point[0] - interaction.center[0]
+ )
+ const deltaAngle = Math.atan2(
+ Math.sin(currentAngle - interaction.startPointerAngle),
+ Math.cos(currentAngle - interaction.startPointerAngle)
+ )
+ next[interaction.side < 0 ? 'leftAngle' : 'rightAngle'] =
+ interaction.expression[interaction.side < 0 ? 'leftAngle' : 'rightAngle'] +
+ (deltaAngle * 180) / Math.PI
+ }
+ if (canvasManipulation.current) {
+ previewManipulation(canvasManipulation.current, next, value => onPreview(value, 'eyes'))
+ }
+ }
+ const commitDrag = () => {
+ const interaction = drag.current
+ const session = canvasManipulation.current
+ if (interaction && session) {
+ finishManipulation(session, 'commit', {
+ preview: value => onPreview(value, interaction.type === 'arcball' ? 'head' : 'eyes'),
+ commit: interaction.type === 'arcball' ? onChange : (onEyeChange ?? onChange),
+ })
+ }
+ canvasManipulation.current = null
+ drag.current = null
+ setActiveDragType(null)
+ onHighlightChange(null)
+ }
+ const cancelDrag = () => {
+ const interaction = drag.current
+ const session = canvasManipulation.current
+ if (interaction && session) {
+ finishManipulation(session, 'cancel', {
+ preview: value => onPreview(value, interaction.type === 'arcball' ? 'head' : 'eyes'),
+ commit: onChange,
+ })
+ }
+ canvasManipulation.current = null
+ drag.current = null
+ setActiveDragType(null)
+ onHighlightChange(null)
+ }
+ useEscapeToCancel(cancelDrag)
+ return (
+
+ {playback && (
+
+
+
+ )}
+
+
+
+
+
+
+
+ {backPaths.map((pathValue, index) => (
+ selectBodyPath(event, backNodeIds.current[index])}
+ />
+ ))}
+ {
+ onBodyNodeSelect('primary')
+ startDrag(event)
+ }}
+ />
+
+ {(showWire || highlight === 'head') &&
+ wirePaths.map((pathValue, index) => (
+
+ ))}
+ selectEye(-1, event)}
+ />
+ selectEye(1, event)}
+ />
+
+ {frontPaths.map((pathValue, index) => (
+ selectBodyPath(event, frontNodeIds.current[index])}
+ />
+ ))}
+
+ {selectedBodyPath && (
+
+ )}
+ {bodyEditing && selectedBodyNode && (
+
+ )}
+ {editor?.visible && (
+
+ {activeDragType !== null && activeDragType !== 'arcball' && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
onHighlightChange(active ? 'head' : null)}
+ onReset={() => onReset({ ...expression, headX: 0, headY: 0, headZ: 0 })}
+ />
+
+ X Y Z
+
+
+ )
+}
+
+type EyeHandle = 'width' | 'height' | 'size' | 'spacing' | 'rotate'
+type HandleStart = (type: EyeHandle, event: React.PointerEvent) => void
+
+function EditorCircle({
+ point,
+ label,
+ type,
+ onStart,
+}: {
+ point: Point3
+ label: string
+ type: EyeHandle
+ onStart: HandleStart
+}) {
+ return (
+ onStart(type, event)}
+ >
+
+
+ {label}
+
+
+ )
+}
+
+function EditorSquare({
+ point,
+ label,
+ type,
+ onStart,
+}: {
+ point: Point3
+ label: string
+ type: EyeHandle
+ onStart: HandleStart
+}) {
+ return (
+ onStart(type, event)}
+ >
+
+
+ {label}
+
+
+ )
+}
+
+function SurfaceThumbnail({ surface }: { surface: SurfaceConfig }) {
+ const geometry = getPreviewGeometry(defaultExpression, surface, emptyBodyNodes)
+ return (
+
+ {geometry.backPaths.map((pathValue, index) => (
+
+ ))}
+
+
+ )
+}
+
+function ExpressionPreview({
+ expression,
+ surface,
+ bodyNodes,
+ colors,
+ avatarEyes,
+ id,
+}: {
+ expression: Expression
+ surface: SurfaceConfig
+ bodyNodes: BodyNode[]
+ colors: AvatarColors
+ avatarEyes: AvatarEyeDefaults
+ id: string
+}) {
+ const geometry = getPreviewGeometry(expression, surface, bodyNodes, avatarEyes)
+ const resolvedColors = resolveColors(expression, colors)
+ const clipId = `preview-${id}`
+ return (
+
+
+
+
+
+
+ {geometry.backPaths.map((pathValue, index) => (
+
+ ))}
+
+
+
+
+
+ {geometry.frontPaths.map((pathValue, index) => (
+
+ ))}
+
+ )
+}
+
+function ExpressionCard({
+ expression,
+ index,
+ active,
+ surface,
+ bodyNodes,
+ colors,
+ avatarEyes,
+ previewId,
+ onSelect,
+ onEdit,
+ onDuplicate,
+ onDelete,
+ draggable,
+ onDragStart,
+ onDragEnter,
+ onDragOver,
+ onDrop,
+ onDragEnd,
+}: {
+ expression: Expression
+ index: number
+ active: boolean
+ surface: SurfaceConfig
+ bodyNodes: BodyNode[]
+ colors: AvatarColors
+ avatarEyes: AvatarEyeDefaults
+ previewId: string
+ onSelect: () => void
+ onEdit?: () => void
+ onDuplicate?: () => void
+ onDelete?: () => void
+ draggable?: boolean
+ onDragStart?: (event: React.DragEvent) => void
+ onDragEnter?: () => void
+ onDragOver?: (event: React.DragEvent) => void
+ onDrop?: (event: React.DragEvent) => void
+ onDragEnd?: () => void
+}) {
+ const { t } = useStudioLanguage()
+ const card = (
+
+
+ {String(index).padStart(2, '0')}
+
+ )
+ if (!onEdit) return card
+ return (
+
+
+
+
+ {t('Modifier')}
+
+ {onDuplicate && (
+
+ {t('Dupliquer')}
+
+ )}
+ {onDelete && (
+ <>
+
+
+ {t('Supprimer')}
+
+ >
+ )}
+
+
+ )
+}
+
+function ExpressionWorkspace({
+ editing,
+ avatarColors,
+ backButtonRef,
+ onChange,
+ onCancel,
+ onSave,
+ onDuplicate,
+ onDelete,
+}: {
+ editing: { index: number | null; draft: Expression }
+ avatarColors: AvatarColors
+ backButtonRef: RefObject
+ onChange: (draft: Expression) => void
+ onCancel: () => void
+ onSave: () => void
+ onDuplicate: () => void
+ onDelete: () => void
+}) {
+ const { t } = useStudioLanguage()
+ const [linked, setLinked] = useState({
+ width: true,
+ height: true,
+ size: true,
+ rotation: true,
+ })
+ const update = (changes: Partial) => onChange({ ...editing.draft, ...changes })
+ const updateDimension = (side: Side, dimension: 'width' | 'height', value: number) => {
+ onChange(updateEyeDimension(editing.draft, side, dimension, value, linked[dimension]))
+ }
+ const updateSize = (side: Side, value: number) => {
+ onChange(scaleEye(editing.draft, side, value, linked.size))
+ }
+ const updateRotation = (side: Side, value: number) => {
+ onChange({
+ ...editing.draft,
+ [side === 'Left' ? 'leftAngle' : 'rightAngle']: value,
+ ...(linked.rotation ? { [side === 'Left' ? 'rightAngle' : 'leftAngle']: -value } : {}),
+ })
+ }
+
+ return (
+ <>
+
+
+
+
+
+ {t('Couleur du corps')}
+ update({ bodyColor })}
+ />
+ {editing.draft.bodyColor && (
+ {
+ const draft = { ...editing.draft }
+ delete draft.bodyColor
+ onChange(draft)
+ }}
+ >
+
+
+ )}
+
+
+ {t('Mouvement perpétuel')}
+ update({ bodyMotion })}
+ />
+
+ {t('Ajoute une légère présence ou un tremblement continu au corps.')}
+
+
+
+ {t('Rotation de la tête')}
+ {(['headX', 'headY', 'headZ'] as const).map(field => (
+ update({ [field]: value })}
+ />
+ ))}
+
+
+
+
+ {t('Couleur des yeux')}
+ update({ eyeColor })}
+ />
+ {editing.draft.eyeColor && (
+ {
+ const draft = { ...editing.draft }
+ delete draft.eyeColor
+ onChange(draft)
+ }}
+ >
+
+
+ )}
+
+
+ {t('Mouvement perpétuel')}
+ update({ eyeMotion })}
+ />
+
+ {t('Anime le regard par petites saccades naturelles ou par tremblement.')}
+
+
+ {(['width', 'height', 'size'] as const).map(dimension => (
+
+
+
+ {t(
+ { width: 'Largeur', height: 'Hauteur', size: 'Taille proportionnelle' }[
+ dimension
+ ]
+ )}
+
+
+ setLinked(current => ({ ...current, [dimension]: !current[dimension] }))
+ }
+ />
+
+
+ {(['Left', 'Right'] as Side[]).map(side => {
+ const width = editing.draft[`width${side}`]
+ const height = editing.draft[`height${side}`]
+ const value =
+ dimension === 'width'
+ ? width
+ : dimension === 'height'
+ ? height
+ : Math.max(width, height)
+ return (
+
+ dimension === 'size'
+ ? updateSize(side, next)
+ : updateDimension(side, dimension, next)
+ }
+ />
+ )
+ })}
+
+
+ ))}
+
+ {t('Position et espacement')}
+
+ {(['Left', 'Right'] as Side[]).map(side => (
+
+
{t(side === 'Left' ? 'Œil gauche' : 'Œil droit')}
+ update({ [`positionX${side}`]: value })}
+ />
+ update({ [`positionY${side}`]: value })}
+ />
+
+ ))}
+
+
+ update({ spacing: value })}
+ />
+
+
+
+
+
{t('Rotation locale')}
+
+ setLinked(current => ({ ...current, rotation: !current.rotation }))
+ }
+ />
+
+
+ updateRotation('Left', value)}
+ />
+ updateRotation('Right', value)}
+ />
+
+
+
+
+
+ update({ perspective: value })}
+ />
+
+
+
+
+
+ >
+ )
+}
+
+function SequenceWorkspace({
+ editing,
+ expressions,
+ surface,
+ bodyNodes,
+ colors,
+ avatarEyes,
+ selectedStepId,
+ backButtonRef,
+ reduceMotion,
+ onSelectedStepChange,
+ onChange,
+ onPreviewStep,
+ onEditExpression,
+ onPlay,
+ onPause,
+ onStop,
+ playing,
+ active,
+ onCancel,
+ onSave,
+ onDuplicate,
+ onDelete,
+}: {
+ editing: { sourceId: string | null; draft: AvatarSequence }
+ expressions: Expression[]
+ surface: SurfaceConfig
+ bodyNodes: BodyNode[]
+ colors: AvatarColors
+ avatarEyes: AvatarEyeDefaults
+ selectedStepId: string | null
+ backButtonRef: RefObject
+ reduceMotion: boolean
+ onSelectedStepChange: (id: string | null) => void
+ onChange: (draft: AvatarSequence) => void
+ onPreviewStep: (step: SequenceStep) => void
+ onEditExpression: (index: number, expression: Expression) => void
+ onPlay: () => void
+ onPause: () => void
+ onStop: () => void
+ playing: boolean
+ active: boolean
+ onCancel: () => void
+ onSave: () => void
+ onDuplicate: () => void
+ onDelete: () => void
+}) {
+ const { t } = useStudioLanguage()
+ const draggedStepId = useRef(null)
+ const selectedStep =
+ editing.draft.steps.find(step => step.id === selectedStepId) ?? editing.draft.steps[0]
+
+ const updateStep = (changes: Partial) => {
+ if (!selectedStep) return
+ onChange({
+ ...editing.draft,
+ steps: editing.draft.steps.map(step =>
+ step.id === selectedStep.id ? { ...step, ...changes } : step
+ ),
+ })
+ }
+
+ const moveStep = (targetId: string) => {
+ const draggedId = draggedStepId.current
+ if (!draggedId || draggedId === targetId) return
+ const dragged = editing.draft.steps.find(step => step.id === draggedId)
+ const targetIndex = editing.draft.steps.findIndex(step => step.id === targetId)
+ if (!dragged || targetIndex < 0) return
+ const next = editing.draft.steps.filter(step => step.id !== draggedId)
+ next.splice(targetIndex, 0, dragged)
+ onChange({ ...editing.draft, steps: next })
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+ {t('Nom')}
+ onChange({ ...editing.draft, name: event.currentTarget.value })}
+ />
+
+
+ {t('Catégorie')}
+ value && onChange({ ...editing.draft, group: value })}
+ >
+
+
+
+
+ {t('Cycle de vie')}
+ {t('Réactions')}
+ {t('Custom')}
+
+
+
+
+ {t('Description')}
+
+ onChange({ ...editing.draft, description: event.currentTarget.value })
+ }
+ />
+
+
+ {t('Mode de lecture')}
+
+ value &&
+ onChange({
+ ...editing.draft,
+ playbackMode: value as AvatarSequence['playbackMode'],
+ })
+ }
+ >
+
+
+
+
+ {t('Boucle')}
+ {t('Une fois')}
+ {t('Aller-retour')}
+
+
+
+
+
+
+
+
+ {editing.draft.steps.length ? (
+
+ {editing.draft.steps.map((step, position) => {
+ const expressionIndex = findExpressionIndex(expressions, step.expressionId)
+ const preset = expressions[expressionIndex]
+ if (!preset) return null
+ const card = (
+
{
+ onSelectedStepChange(step.id)
+ onPreviewStep(step)
+ }}
+ onDoubleClick={() => onEditExpression(expressionIndex, preset)}
+ >
+
+
+ {String(expressionIndex).padStart(2, '0')}
+ {position + 1}
+
+ )
+ return (
+
{
+ draggedStepId.current = step.id
+ onSelectedStepChange(step.id)
+ }}
+ onDragEnter={() => moveStep(step.id)}
+ onDragOver={event => event.preventDefault()}
+ onDragEnd={() => {
+ draggedStepId.current = null
+ }}
+ >
+
+
+
+ onEditExpression(expressionIndex, preset)}
+ >
+ {t('Modifier')}
+
+
+
+
+ )
+ })}
+
+ ) : (
+ {t('Ajoute une expression pour commencer.')}
+ )}
+
+ {selectedStep ? (
+
+
+
+
{t('Étape sélectionnée')}
+
{t('Durée visible avant de passer à l’expression suivante.')}
+
+
{
+ const next = editing.draft.steps.filter(step => step.id !== selectedStep.id)
+ onChange({ ...editing.draft, steps: next })
+ onSelectedStepChange(next[0]?.id ?? null)
+ }}
+ aria-label={t('Supprimer cette étape')}
+ >
+
+
+
+
updateStep({ holdMs })}
+ />
+ updateStep({ transitionMs })}
+ />
+
+ {t('Transition')}
+
+ value && updateStep({ transition: value as SequenceStep['transition'] })
+ }
+ >
+
+
+
+
+ {t('Ressort')}
+ {t('Douce')}
+ {t('Rapide')}
+
+
+
+
+ ) : null}
+
+
+
+
+
+ {expressions.map((preset, index) => (
+ {
+ const step = createSequenceStep(preset.id)
+ onChange({ ...editing.draft, steps: [...editing.draft.steps, step] })
+ onSelectedStepChange(step.id)
+ onPreviewStep(step)
+ }}
+ >
+
+ {String(index).padStart(2, '0')}
+
+ ))}
+
+
+
+
+
+
+
+ {t('Activer les clignements')}
+
+ onChange({
+ ...editing.draft,
+ blink: { ...editing.draft.blink, enabled },
+ })
+ }
+ />
+
+
+ onChange({
+ ...editing.draft,
+ blink: { ...editing.draft.blink, initialDelayMs },
+ })
+ }
+ />
+
+
+ onChange({
+ ...editing.draft,
+ blink: {
+ ...editing.draft.blink,
+ minIntervalMs,
+ maxIntervalMs: Math.max(minIntervalMs, editing.draft.blink.maxIntervalMs),
+ },
+ })
+ }
+ />
+
+ onChange({
+ ...editing.draft,
+ blink: { ...editing.draft.blink, maxIntervalMs },
+ })
+ }
+ />
+
+
+ onChange({
+ ...editing.draft,
+ blink: { ...editing.draft.blink, durationMs },
+ })
+ }
+ />
+
+
+
+
+
+ >
+ )
+}
+
+function StudioApp() {
+ const { language, setLanguage, t } = useStudioLanguage()
+ const [mode, setMode] = useState('manual')
+ const [initialDocument] = useState(loadStudioDocument)
+ const [documentStore] = useState(() => createStudioDocumentStore(initialDocument))
+ const initialLibrary = initialDocument.library
+ const [avatars, setAvatars] = useState(initialLibrary.avatars)
+ const [activeAvatarId, setActiveAvatarId] = useState(initialLibrary.activeAvatarId)
+ const initialAvatar =
+ initialLibrary.avatars.find(avatar => avatar.id === initialLibrary.activeAvatarId) ??
+ initialLibrary.avatars[0]
+ const [baseBehavior] = useState(() => ({
+ expressions: initialDocument.expressions,
+ sequences: initialDocument.sequences,
+ }))
+ const initialBehavior = resolveAvatarBehavior(initialAvatar, baseBehavior)
+ const [surface, setSurface] = useState(initialAvatar.body.primary)
+ const [bodyNodes, setBodyNodes] = useState(initialAvatar.body.nodes)
+ const [selectedBodyNodeId, setSelectedBodyNodeId] = useState<'primary' | string | null>('primary')
+ const [selectedEyeSide, setSelectedEyeSide] = useState<-1 | 1 | null>(null)
+ const [expressions, setExpressions] = useState(initialBehavior.expressions)
+ const [sequences, setSequences] = useState(initialBehavior.sequences)
+ const [exportFormat, setExportFormat] = useState('react')
+ const [exportAnimationIds, setExportAnimationIds] = useState(() =>
+ initialBehavior.sequences.map(animation => animation.id)
+ )
+ const [snapshotBackground, setSnapshotBackground] = useState('transparent')
+ const [snapshotColorFrom, setSnapshotColorFrom] = useState('#F5F7FC')
+ const [snapshotColorTo, setSnapshotColorTo] = useState('#C9D5FF')
+ const [snapshotSize, setSnapshotSize] = useState('1024')
+ const [snapshotFormat, setSnapshotFormat] = useState('png')
+ const [photoFlash, setPhotoFlash] = useState(0)
+ const initialStatePlayback = initialDocument.playback
+ const updateStudioLibrary = (library: typeof initialDocument.library) =>
+ documentStore.update({ library })
+ const persistStatePlayback = (playback: StatePlaybackSelection) =>
+ documentStore.update({ playback })
+ const [bodyEditing, setBodyEditing] = useState(false)
+ const modeRef = useRef(mode)
+ useEffect(() => {
+ modeRef.current = mode
+ }, [mode])
+ const avatarEditSnapshot = useRef<{
+ avatars: StudioAvatar[]
+ activeAvatarId: string
+ } | null>(null)
+ const workspaceBackButtonRef = useRef(null)
+ const [focusAvatarName, setFocusAvatarName] = useState(false)
+ const initialExpression = expressions[0] ?? defaultExpression
+ const [expression, setExpression] = useState({ ...initialExpression })
+ const initialDisplayColors = resolveColors(initialExpression, initialAvatar.colors)
+ const [renderedColors] = useState(() => createRenderedColors(initialDisplayColors))
+ const setDisplayColors = (next: AvatarColors) => {
+ paintRenderedColors(renderedColors, next)
+ }
+ const [deleteAvatarOpen, setDeleteAvatarOpen] = useState(false)
+ const [deleteExpressionOpen, setDeleteExpressionOpen] = useState(false)
+ const [deleteSequenceOpen, setDeleteSequenceOpen] = useState(false)
+ const [pendingProjectImport, setPendingProjectImport] = useState<{
+ document: StudioDocument
+ fileName: string
+ } | null>(null)
+ const [projectImportError, setProjectImportError] = useState(null)
+ const projectImportRef = useRef(null)
+ const [statePlayerExpanded, setStatePlayerExpanded] = useState(false)
+ const [activeExpression, setActiveExpression] = useState(null)
+ const [editing, setEditing] = useState<{ index: number | null; draft: Expression } | null>(null)
+ const [showWire, setShowWire] = useState(false)
+ const [springSpeed, setSpringSpeed] = useState(7)
+ const [linked, setLinked] = useState({
+ width: true,
+ height: true,
+ size: true,
+ position: true,
+ rotation: true,
+ })
+ const [highlight, setHighlight] = useState(null)
+ const [selectedState, setSelectedState] = useState(() =>
+ sequences.some(sequence => sequence.id === initialStatePlayback.stateId)
+ ? initialStatePlayback.stateId!
+ : sequences.some(sequence => sequence.id === 'idle')
+ ? 'idle'
+ : (sequences[0]?.id ?? '')
+ )
+ const [activeState, setActiveState] = useState(null)
+ const [statePlaying, setStatePlaying] = useState(false)
+ const [playbackStatus, setPlaybackStatus] = useState(() =>
+ initialStatePlayback.stateId === null ? 'stopped' : 'paused'
+ )
+ const [playbackVisual, setPlaybackVisual] = useState({
+ position: null as number | null,
+ run: 0,
+ durationMs: 0,
+ })
+ const [sequenceEditing, setSequenceEditing] = useState<{
+ sourceId: string | null
+ draft: AvatarSequence
+ } | null>(null)
+ const [selectedSequenceStepId, setSelectedSequenceStepId] = useState(null)
+ const stateTimer = useRef | null>(null)
+ const activeSequenceRef = useRef(null)
+ const editorStateSnapshot = useRef<{
+ stateId: string
+ playing: boolean
+ expression: Expression
+ } | null>(null)
+ const initialStatePlaybackApplied = useRef(false)
+ const blinkTimer = useRef | null>(null)
+ const [initialPlaybackTimeline] = useState(createPlaybackTimeline)
+ const playbackTimeline = useRef(initialPlaybackTimeline)
+ const reduceMotion = useReducedMotion()
+
+ const avatarsRef = useRef(avatars)
+ const expressionsRef = useRef(expressions)
+ const sequencesRef = useRef(sequences)
+ const draggedAvatarId = useRef(null)
+ const avatarDragOrigin = useRef(null)
+ const avatarDragPreview = useRef(avatars)
+ const [draggingAvatarId, setDraggingAvatarId] = useState(null)
+ const draggedExpressionId = useRef(null)
+ const expressionDragOrigin = useRef(null)
+ const expressionDragPreview = useRef(expressions)
+ const [draggingExpressionId, setDraggingExpressionId] = useState(null)
+ const draggedStateId = useRef(null)
+ const stateDragOrigin = useRef(null)
+ const stateDragPreview = useRef(sequences)
+ const [draggingStateId, setDraggingStateId] = useState(null)
+ const activeAvatarIdRef = useRef(activeAvatarId)
+ const surfaceRef = useRef(surface)
+ const bodyNodesRef = useRef(bodyNodes)
+ const showWireRef = useRef(showWire)
+ const highlightRef = useRef(highlight)
+ const persistActiveBehavior = (
+ nextExpressions: Expression[],
+ nextSequences: AvatarSequence[]
+ ) => {
+ const behavior = cloneAvatarBehavior({
+ expressions: nextExpressions,
+ sequences: nextSequences,
+ })
+ const nextAvatars = avatarsRef.current.map(avatar =>
+ avatar.id === activeAvatarIdRef.current ? { ...avatar, behavior } : avatar
+ )
+ avatarsRef.current = nextAvatars
+ setAvatars(nextAvatars)
+ updateStudioLibrary({ activeAvatarId: activeAvatarIdRef.current, avatars: nextAvatars })
+ }
+ const updateStudioExpressions = (nextExpressions: Expression[]) => {
+ expressionsRef.current = nextExpressions
+ persistActiveBehavior(nextExpressions, sequencesRef.current)
+ }
+ const updateStudioSequences = (nextSequences: AvatarSequence[]) => {
+ sequencesRef.current = nextSequences
+ persistActiveBehavior(expressionsRef.current, nextSequences)
+ }
+ const [initialRender] = useState(() => {
+ const pose = poseFromExpression(initialExpression)
+ return {
+ pose,
+ geometry: renderAvatar(
+ poseWithAvatarEyes(initialExpression, initialAvatar.eyes),
+ surface,
+ 1,
+ { bodyNodes }
+ ),
+ }
+ })
+ const { pose: initialPose, geometry: initialGeometry } = initialRender
+ const displayedPose = useRef(initialPose)
+ const transitionFrame = useRef(null)
+ const ambientFrame = useRef(null)
+ const ambientStartedAt = useRef(0)
+ const lastAmbientElapsed = useRef(0)
+ const lastAmbientFrame = useRef(0)
+ const ambientSignature = useRef('none:none')
+ const transitionTarget = useRef({ ...initialExpression })
+ const canonicalTarget = useRef({ ...initialExpression })
+ const retargetFrom = useRef(null)
+ const retargetTo = useRef(null)
+ const retargetStartedAt = useRef(null)
+ const [transitionVelocity] = useState(
+ () =>
+ Object.fromEntries(expressionFields.map(field => [field, 0])) as Record<
+ (typeof expressionFields)[number],
+ number
+ >
+ )
+ const lastTransitionTime = useRef(null)
+ const lastInspectorFrame = useRef(0)
+ const springSpeedRef = useRef(springSpeed)
+ const sequenceTransitionRef = useRef>({
+ transitionMs: 500,
+ transition: 'smooth',
+ })
+ const activeSequenceTransition = useRef<{
+ target: Expression
+ index: number | null
+ settings: Pick
+ remainingMs: number
+ } | null>(null)
+ const pausedSequenceTransition = useRef(null)
+ const blinkControls = useRef | null>(null)
+ const blinkAnimating = useRef(false)
+ const blinkValue = useMotionValue(1)
+ const [renderedScene] = useState(() => createRenderedScene(initialGeometry))
+ const [renderedRotationGizmo] = useState(() => createRenderedRotationGizmo(initialExpression))
+ const bodyColorAnimation = useRef | null>(null)
+ const eyeColorAnimation = useRef | null>(null)
+
+ const paintPose = (pose: AvatarPose, blink?: number, frameTimeMs?: number) => {
+ displayedPose.current = pose
+ paintRenderedRotationGizmo(renderedRotationGizmo, pose.expression)
+ const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
+ const signature = `${pose.expression.eyeMotion}:${pose.expression.bodyMotion}`
+ if (signature !== ambientSignature.current) {
+ ambientSignature.current = signature
+ ambientStartedAt.current = -1
+ lastAmbientElapsed.current = 0
+ }
+ if (frameTimeMs !== undefined) {
+ if (ambientStartedAt.current < 0) ambientStartedAt.current = frameTimeMs
+ lastAmbientElapsed.current = frameTimeMs - ambientStartedAt.current
+ }
+ const renderedExpression =
+ !reduceMotion && hasAmbientMotion(pose.expression)
+ ? applyAmbientMotion(pose.expression, lastAmbientElapsed.current)
+ : pose.expression
+ const renderPose = avatar
+ ? poseWithAvatarEyes(renderedExpression, avatar.eyes ?? defaultAvatarEyes)
+ : poseFromExpression(renderedExpression)
+ const geometry = renderAvatar(renderPose, surfaceRef.current, blink ?? blinkValue.get(), {
+ includeWire: showWireRef.current || highlightRef.current === 'head',
+ bodyNodes: bodyNodesRef.current,
+ })
+ paintRenderedScene(renderedScene, geometry)
+ paintRenderedOffset(
+ renderedScene,
+ ambientBodyOffset(pose.expression, lastAmbientElapsed.current)
+ )
+ }
+
+ useMotionValueEvent(blinkValue, 'change', latest => paintPose(displayedPose.current, latest))
+
+ const paintAmbientFrame = useEffectEvent((time: number) => {
+ if (transitionFrame.current === null && time - lastAmbientFrame.current >= AMBIENT_FRAME_MS) {
+ lastAmbientFrame.current = time
+ paintPose(displayedPose.current, undefined, time)
+ }
+ })
+
+ const ambientLoopActive = !reduceMotion && hasAmbientMotion(editing?.draft ?? expression)
+ useEffect(() => {
+ if (!ambientLoopActive) return
+ const tick = (time: number) => {
+ paintAmbientFrame(time)
+ ambientFrame.current = requestAnimationFrame(tick)
+ }
+ ambientFrame.current = requestAnimationFrame(tick)
+ return () => {
+ if (ambientFrame.current !== null) cancelAnimationFrame(ambientFrame.current)
+ }
+ }, [ambientLoopActive])
+
+ useEffect(
+ () => () => {
+ if (transitionFrame.current !== null) cancelAnimationFrame(transitionFrame.current)
+ blinkControls.current?.stop()
+ bodyColorAnimation.current?.stop()
+ eyeColorAnimation.current?.stop()
+ if (stateTimer.current) clearTimeout(stateTimer.current)
+ if (blinkTimer.current) clearTimeout(blinkTimer.current)
+ },
+ []
+ )
+
+ const stopTransition = (resetVelocity: boolean) => {
+ if (transitionFrame.current !== null) cancelAnimationFrame(transitionFrame.current)
+ transitionFrame.current = null
+ lastTransitionTime.current = null
+ retargetFrom.current = null
+ retargetTo.current = null
+ retargetStartedAt.current = null
+ lastInspectorFrame.current = 0
+ if (resetVelocity) {
+ expressionFields.forEach(field => {
+ transitionVelocity[field] = 0
+ })
+ }
+ }
+
+ const stopColorTransitions = () => {
+ bodyColorAnimation.current?.stop()
+ eyeColorAnimation.current?.stop()
+ bodyColorAnimation.current = null
+ eyeColorAnimation.current = null
+ }
+
+ const freezeLivePreviewForManipulation = () => {
+ if (statePlaying) pauseState()
+ const renderedExpression = { ...displayedPose.current.expression }
+ stopTransition(true)
+ stopColorTransitions()
+ activeSequenceTransition.current = null
+ pausedSequenceTransition.current = null
+ transitionTarget.current = renderedExpression
+ canonicalTarget.current = renderedExpression
+ setExpression(renderedExpression)
+ setActiveExpression(null)
+ return renderedExpression
+ }
+
+ const updateImmediate = (next: Expression, preservePlayback = false) => {
+ if (!preservePlayback && statePlaying) pauseState()
+ stopTransition(true)
+ stopColorTransitions()
+ const pose = poseFromExpression(next)
+ transitionTarget.current = next
+ canonicalTarget.current = next
+ setExpression(next)
+ const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
+ if (avatar) setDisplayColors(resolveColors(next, avatar.colors))
+ setActiveExpression(null)
+ paintPose(pose)
+ }
+
+ const transitionToExpression = (
+ next: Expression,
+ index: number | null = null,
+ transitionSettings?: Pick
+ ) => {
+ if (!transitionSettings && statePlaying) pauseState()
+ sequenceTransitionRef.current = transitionSettings ?? {
+ transitionMs: 500,
+ transition: 'smooth',
+ }
+ setActiveExpression(index)
+ const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
+ if (reduceMotion || transitionSettings?.transitionMs === 0) {
+ updateImmediate(next, Boolean(transitionSettings))
+ setActiveExpression(index)
+ return
+ }
+ const current = displayedPose.current.expression
+ const nearestAngle = (target: number, from: number) => {
+ let resolved = target
+ while (resolved - from > 180) resolved -= 360
+ while (resolved - from < -180) resolved += 360
+ return resolved
+ }
+ canonicalTarget.current = next
+ const resolvedTarget = {
+ ...next,
+ headX: nearestAngle(next.headX, current.headX),
+ headY: nearestAngle(next.headY, current.headY),
+ headZ: nearestAngle(next.headZ, current.headZ),
+ leftAngle: nearestAngle(next.leftAngle, current.leftAngle),
+ rightAngle: nearestAngle(next.rightAngle, current.rightAngle),
+ }
+
+ if (transitionSettings) {
+ stopTransition(true)
+ stopColorTransitions()
+ const durationMs = transitionSettings.transitionMs
+ const from = { ...current }
+ const fromColors = {
+ body: renderedColors.body.get(),
+ eyes: renderedColors.eyes.get(),
+ }
+ const targetColors = avatar ? resolveColors(next, avatar.colors) : fromColors
+ let startedAt: number | null = null
+ activeSequenceTransition.current = {
+ target: next,
+ index,
+ settings: transitionSettings,
+ remainingMs: durationMs,
+ }
+ const tickSequenceTransition = (time: number) => {
+ if (startedAt === null) startedAt = time
+ const elapsed = time - startedAt
+ const progress = Math.min(elapsed / durationMs, 1)
+ const eased =
+ transitionSettings.transition === 'smooth'
+ ? progress * progress * (3 - 2 * progress)
+ : transitionSettings.transition === 'snappy'
+ ? 1 - (1 - progress) ** 3
+ : 1 - Math.exp(-6 * progress) * Math.cos(8 * progress)
+ const animated = { ...from, eyeMotion: next.eyeMotion, bodyMotion: next.bodyMotion }
+ expressionFields.forEach(field => {
+ animated[field] = from[field] + (resolvedTarget[field] - from[field]) * eased
+ })
+ activeSequenceTransition.current = {
+ target: next,
+ index,
+ settings: transitionSettings,
+ remainingMs: Math.max(durationMs - elapsed, 0),
+ }
+ setDisplayColors({
+ body: interpolateHexColor(fromColors.body, targetColors.body, bounded(eased, 0, 1)),
+ eyes: interpolateHexColor(fromColors.eyes, targetColors.eyes, bounded(eased, 0, 1)),
+ })
+ paintPose(poseFromExpression(animated), undefined, time)
+ if (
+ modeRef.current === 'manual' &&
+ time - lastInspectorFrame.current >= INSPECTOR_FRAME_MS
+ ) {
+ lastInspectorFrame.current = time
+ setExpression(animated)
+ }
+ if (progress < 1) {
+ transitionFrame.current = requestAnimationFrame(tickSequenceTransition)
+ return
+ }
+ transitionFrame.current = null
+ activeSequenceTransition.current = null
+ canonicalTarget.current = next
+ transitionTarget.current = next
+ setExpression(next)
+ setDisplayColors(targetColors)
+ paintPose(poseFromExpression(next))
+ }
+ transitionFrame.current = requestAnimationFrame(tickSequenceTransition)
+ return
+ }
+
+ if (avatar) {
+ const targetColors = resolveColors(next, avatar.colors)
+ bodyColorAnimation.current = animate(renderedColors.body, targetColors.body, {
+ duration: 0.35,
+ ease: 'easeInOut',
+ })
+ eyeColorAnimation.current = animate(renderedColors.eyes, targetColors.eyes, {
+ duration: 0.35,
+ ease: 'easeInOut',
+ })
+ }
+
+ if (transitionFrame.current !== null) {
+ retargetFrom.current = { ...transitionTarget.current }
+ retargetTo.current = resolvedTarget
+ retargetStartedAt.current = -1
+ return
+ }
+ transitionTarget.current = resolvedTarget
+ const tick = (time: number) => {
+ const previousTime = lastTransitionTime.current ?? time
+ const deltaTime = Math.min(Math.max((time - previousTime) / 1000, 1 / 240), 1 / 30)
+ lastTransitionTime.current = time
+ const { stiffness, damping } = getSequenceSpring(
+ sequenceTransitionRef.current.transition,
+ sequenceTransitionRef.current.transitionMs,
+ springSpeedRef.current
+ )
+ const mass = 0.85
+ const currentExpression = displayedPose.current.expression
+ if (retargetStartedAt.current !== null && retargetFrom.current && retargetTo.current) {
+ if (retargetStartedAt.current < 0) retargetStartedAt.current = time
+ const linearProgress = Math.min((time - retargetStartedAt.current) / RETARGET_BLEND_MS, 1)
+ const smoothProgress =
+ linearProgress ** 3 * (linearProgress * (linearProgress * 6 - 15) + 10)
+ const blendedTarget = { ...retargetTo.current }
+ expressionFields.forEach(field => {
+ blendedTarget[field] =
+ retargetFrom.current![field] +
+ (retargetTo.current![field] - retargetFrom.current![field]) * smoothProgress
+ })
+ transitionTarget.current = blendedTarget
+ if (linearProgress === 1) {
+ transitionTarget.current = retargetTo.current
+ retargetFrom.current = null
+ retargetTo.current = null
+ retargetStartedAt.current = null
+ }
+ }
+ const target = transitionTarget.current
+ let settled = true
+ const animated = { ...currentExpression }
+ animated.eyeMotion = target.eyeMotion
+ animated.bodyMotion = target.bodyMotion
+
+ expressionFields.forEach(field => {
+ const displacement = target[field] - currentExpression[field]
+ const acceleration = (stiffness * displacement - damping * transitionVelocity[field]) / mass
+ const velocity = transitionVelocity[field] + acceleration * deltaTime
+ const value = currentExpression[field] + velocity * deltaTime
+ transitionVelocity[field] = velocity
+ animated[field] = value
+ const tolerance = field === 'perspective' ? 0.0001 : 0.005
+ if (Math.abs(displacement) > tolerance || Math.abs(velocity) > tolerance) settled = false
+ })
+
+ if (settled) {
+ const finalExpression = canonicalTarget.current
+ stopTransition(true)
+ setExpression(finalExpression)
+ paintPose(poseFromExpression(finalExpression))
+ return
+ }
+
+ paintPose(poseFromExpression(animated), undefined, time)
+ if (modeRef.current === 'manual' && time - lastInspectorFrame.current >= INSPECTOR_FRAME_MS) {
+ lastInspectorFrame.current = time
+ setExpression(animated)
+ }
+ transitionFrame.current = requestAnimationFrame(tick)
+ }
+ transitionFrame.current = requestAnimationFrame(tick)
+ }
+
+ const blink = (durationMs?: number) => {
+ blinkControls.current?.stop()
+ blinkValue.jump(1)
+ blinkAnimating.current = true
+ const sequence = sequences.find(item => item.id === (activeState ?? selectedState))
+ const blinkDuration = durationMs ?? sequence?.blink.durationMs ?? 280
+ blinkControls.current = animate(blinkValue, [1, 0, 1], {
+ duration: reduceMotion ? 0 : blinkDuration / 1000,
+ times: [0, 0.42, 1],
+ ease: ['easeIn', 'easeOut'],
+ onComplete: () => {
+ blinkAnimating.current = false
+ },
+ })
+ }
+
+ const updateDimension = (side: Side, dimension: 'width' | 'height', value: number) => {
+ updateImmediate(updateEyeDimension(expression, side, dimension, value, linked[dimension]))
+ }
+
+ const updateSize = (side: Side, value: number) => {
+ updateImmediate(scaleEye(expression, side, value, linked.size))
+ }
+
+ const updateSpacing = (value: number) => {
+ updateImmediate({ ...expression, spacing: value })
+ }
+
+ const updateActiveAvatar = (update: (avatar: StudioAvatar) => StudioAvatar) => {
+ const next = avatarsRef.current.map(avatar =>
+ avatar.id === activeAvatarIdRef.current ? update(avatar) : avatar
+ )
+ avatarsRef.current = next
+ setAvatars(next)
+ if (!avatarEditSnapshot.current) {
+ updateStudioLibrary({ activeAvatarId: activeAvatarIdRef.current, avatars: next })
+ }
+ }
+
+ const selectBodyNode = (id: 'primary' | string | null) => {
+ setSelectedBodyNodeId(id)
+ if (id) setSelectedEyeSide(null)
+ }
+
+ const updateSurface = (next: SurfaceConfig) => {
+ surfaceRef.current = next
+ setSurface(next)
+ updateActiveAvatar(avatar => ({
+ ...avatar,
+ body: { primary: next, nodes: bodyNodesRef.current },
+ }))
+ paintPose(displayedPose.current)
+ }
+
+ const updateBodyNodes = (next: BodyNode[]) => {
+ bodyNodesRef.current = next
+ setBodyNodes(next)
+ updateActiveAvatar(avatar => ({
+ ...avatar,
+ body: { primary: surfaceRef.current, nodes: next },
+ }))
+ paintPose(displayedPose.current)
+ }
+
+ const updateAvatarColors = (changes: Partial) => {
+ const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
+ if (!avatar) return
+ const colors = { ...avatar.colors, ...changes }
+ updateActiveAvatar(current => ({ ...current, colors }))
+ setDisplayColors(resolveColors(expression, colors))
+ }
+
+ const updateAvatarEyes = (changes: Partial) => {
+ const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
+ if (!avatar) return
+ const eyes = { ...(avatar.eyes ?? defaultAvatarEyes), ...changes }
+ updateActiveAvatar(current => ({ ...current, eyes }))
+ paintPose(displayedPose.current)
+ }
+
+ const activateAvatar = (id: string, editBody = false, preserveMode = false) => {
+ const avatar = avatarsRef.current.find(item => item.id === id)
+ if (!avatar) return
+ const resumeActiveSequence = statePlaying
+ if (resumeActiveSequence) pauseState(false)
+ if (editBody) suspendStateForEditor()
+ if (editBody && !avatarEditSnapshot.current) {
+ avatarEditSnapshot.current = {
+ avatars: avatarsRef.current,
+ activeAvatarId: activeAvatarIdRef.current,
+ }
+ }
+ const currentStateExpression = displayedPose.current.expression
+ const nextBehavior = resolveAvatarBehavior(avatar, baseBehavior)
+ const nextExpressions = nextBehavior.expressions
+ const nextSequences = nextBehavior.sequences
+ stopTransition(true)
+ activeAvatarIdRef.current = id
+ surfaceRef.current = avatar.body.primary
+ bodyNodesRef.current = avatar.body.nodes
+ setActiveAvatarId(id)
+ setSurface(avatar.body.primary)
+ setBodyNodes(avatar.body.nodes)
+ expressionsRef.current = nextExpressions
+ sequencesRef.current = nextSequences
+ expressionDragPreview.current = nextExpressions
+ stateDragPreview.current = nextSequences
+ setExpressions(nextExpressions)
+ setSequences(nextSequences)
+ setExportAnimationIds(nextSequences.map(animation => animation.id))
+ const nextActiveSequence = activeState
+ ? (nextSequences.find(sequence => sequence.id === activeState) ?? null)
+ : null
+ const nextSelectedState =
+ nextActiveSequence?.id ??
+ (nextSequences.some(sequence => sequence.id === 'idle')
+ ? 'idle'
+ : (nextSequences[0]?.id ?? ''))
+ activeSequenceRef.current = nextActiveSequence
+ setActiveState(nextActiveSequence?.id ?? null)
+ setSelectedState(nextSelectedState)
+ setPlaybackVisual(current => ({ ...current, position: null }))
+ selectBodyNode('primary')
+ setActiveExpression(null)
+ setEditing(null)
+ setBodyEditing(editBody)
+ if (!preserveMode || editBody) setMode('manual')
+ const nextExpression = nextActiveSequence
+ ? currentStateExpression
+ : { ...(nextExpressions[0] ?? defaultExpression) }
+ setExpression(nextExpression)
+ setDisplayColors(resolveColors(nextExpression, avatar.colors))
+ canonicalTarget.current = nextExpression
+ transitionTarget.current = nextExpression
+ paintPose(poseFromExpression(nextExpression))
+ if (nextActiveSequence && resumeActiveSequence) {
+ pausedSequenceTransition.current = null
+ launchSequence(nextActiveSequence, true, false)
+ }
+ if (!avatarEditSnapshot.current) {
+ updateStudioLibrary({ activeAvatarId: id, avatars: avatarsRef.current })
+ }
+ }
+
+ const createNewAvatar = () => {
+ avatarEditSnapshot.current = {
+ avatars: avatarsRef.current,
+ activeAvatarId: activeAvatarIdRef.current,
+ }
+ const avatar = createAvatar('Unknown')
+ const next = [...avatarsRef.current, avatar]
+ avatarsRef.current = next
+ setAvatars(next)
+ setFocusAvatarName(true)
+ activateAvatar(avatar.id, true)
+ }
+
+ const duplicateAvatar = (source: StudioAvatar, editDuplicate = false) => {
+ const snapshotAvatars = avatarEditSnapshot.current?.avatars ?? avatarsRef.current
+ const sourceIndex = snapshotAvatars.findIndex(avatar => avatar.id === source.id)
+ const baseAvatars = sourceIndex < 0 ? [...snapshotAvatars, source] : snapshotAvatars
+ const duplicate: StudioAvatar = {
+ ...structuredClone(source),
+ id: `avatar-${crypto.randomUUID()}`,
+ name: `${source.name} ${t('copie')}`,
+ }
+ const next = [...baseAvatars, duplicate]
+ avatarEditSnapshot.current = null
+ avatarsRef.current = next
+ setAvatars(next)
+ updateStudioLibrary({ activeAvatarId: duplicate.id, avatars: next })
+ activateAvatar(duplicate.id, editDuplicate)
+ }
+
+ const previewAvatarMove = (targetId: string) => {
+ const draggedId = draggedAvatarId.current
+ if (!draggedId || draggedId === targetId) return
+ const current = avatarDragPreview.current
+ const dragged = current.find(avatar => avatar.id === draggedId)
+ const targetIndex = current.findIndex(avatar => avatar.id === targetId)
+ if (!dragged || targetIndex < 0) return
+ const next = current.filter(avatar => avatar.id !== draggedId)
+ next.splice(targetIndex, 0, dragged)
+ avatarDragPreview.current = next
+ setAvatars(next)
+ }
+
+ const commitAvatarMove = (targetId: string) => {
+ previewAvatarMove(targetId)
+ const next = avatarDragPreview.current
+ avatarsRef.current = next
+ updateStudioLibrary({ activeAvatarId: activeAvatarIdRef.current, avatars: next })
+ avatarDragOrigin.current = null
+ draggedAvatarId.current = null
+ setDraggingAvatarId(null)
+ }
+
+ const cancelAvatarMove = () => {
+ if (draggedAvatarId.current && avatarDragOrigin.current) {
+ avatarDragPreview.current = avatarDragOrigin.current
+ setAvatars(avatarDragOrigin.current)
+ }
+ avatarDragOrigin.current = null
+ draggedAvatarId.current = null
+ setDraggingAvatarId(null)
+ }
+
+ const cancelAvatarEditing = () => {
+ const snapshot = avatarEditSnapshot.current
+ if (!snapshot) {
+ setBodyEditing(false)
+ restoreStateAfterEditor()
+ return
+ }
+ avatarEditSnapshot.current = null
+ avatarsRef.current = snapshot.avatars
+ setAvatars(snapshot.avatars)
+ activateAvatar(snapshot.activeAvatarId, false, true)
+ restoreStateAfterEditor()
+ }
+
+ const saveAvatarEditing = () => {
+ avatarEditSnapshot.current = null
+ updateStudioLibrary({ activeAvatarId: activeAvatarIdRef.current, avatars: avatarsRef.current })
+ setBodyEditing(false)
+ restoreStateAfterEditor()
+ }
+
+ const renameActiveAvatar = (name: string) => {
+ updateActiveAvatar(avatar => ({ ...avatar, name }))
+ }
+
+ const deleteActiveAvatar = () => {
+ if (avatarsRef.current.length <= 1) return
+ avatarEditSnapshot.current = null
+ const remaining = avatarsRef.current.filter(avatar => avatar.id !== activeAvatarIdRef.current)
+ avatarsRef.current = remaining
+ setAvatars(remaining)
+ updateStudioLibrary({ activeAvatarId: remaining[0].id, avatars: remaining })
+ setDeleteAvatarOpen(false)
+ activateAvatar(remaining[0].id)
+ restoreStateAfterEditor()
+ }
+
+ const addBodyNode = (type: (typeof bodyPrimitiveTypes)[number]) => {
+ if (bodyNodesRef.current.length >= MAX_BODY_NODES) return
+ const node = createBodyNode(type, bodyNodesRef.current.length)
+ updateBodyNodes([...bodyNodesRef.current, node])
+ selectBodyNode(node.id)
+ }
+
+ const updateSelectedBodyNode = (update: (node: BodyNode) => BodyNode) => {
+ if (selectedBodyNodeId === 'primary') return
+ updateBodyNodes(
+ bodyNodesRef.current.map(node => (node.id === selectedBodyNodeId ? update(node) : node))
+ )
+ }
+
+ const commitBodyNode = (nextNode: BodyNode) => {
+ updateBodyNodes(bodyNodesRef.current.map(node => (node.id === nextNode.id ? nextNode : node)))
+ }
+
+ const previewSelectedBodyNode = (nextNode: BodyNode) => {
+ const next = bodyNodesRef.current.map(node => (node.id === nextNode.id ? nextNode : node))
+ bodyNodesRef.current = next
+ setBodyNodes(next)
+ paintPose(displayedPose.current)
+ }
+
+ const deleteSelectedBodyNode = () => {
+ if (selectedBodyNodeId === 'primary') return
+ updateBodyNodes(bodyNodesRef.current.filter(node => node.id !== selectedBodyNodeId))
+ selectBodyNode('primary')
+ }
+
+ const duplicateSelectedBodyNode = () => {
+ if (selectedBodyNodeId === 'primary' || bodyNodesRef.current.length >= MAX_BODY_NODES) return
+ const source = bodyNodesRef.current.find(node => node.id === selectedBodyNodeId)
+ if (!source) return
+ const duplicate = duplicateBodyNode(source)
+ updateBodyNodes([...bodyNodesRef.current, duplicate])
+ selectBodyNode(duplicate.id)
+ }
+
+ const updateHighlight = (next: Highlight) => {
+ if (next && statePlaying) pauseState()
+ highlightRef.current = next
+ setHighlight(next)
+ if (next === 'head') paintPose(displayedPose.current)
+ }
+
+ const updateWireVisibility = (next: boolean) => {
+ showWireRef.current = next
+ setShowWire(next)
+ if (next) paintPose(displayedPose.current)
+ }
+
+ const clearStateTimers = () => {
+ if (stateTimer.current) clearTimeout(stateTimer.current)
+ if (blinkTimer.current) clearTimeout(blinkTimer.current)
+ stateTimer.current = null
+ blinkTimer.current = null
+ playbackTimeline.current = {
+ ...playbackTimeline.current,
+ stepDueAt: null,
+ blinkDueAt: null,
+ }
+ }
+
+ const pauseState = (persist = true) => {
+ playbackTimeline.current = pausePlaybackTimeline(playbackTimeline.current, readSequenceClock())
+ if (transitionFrame.current !== null && activeSequenceTransition.current) {
+ cancelAnimationFrame(transitionFrame.current)
+ transitionFrame.current = null
+ pausedSequenceTransition.current = activeSequenceTransition.current
+ activeSequenceTransition.current = null
+ }
+ if (blinkAnimating.current) blinkControls.current?.pause()
+ clearStateTimers()
+ setStatePlaying(false)
+ setPlaybackStatus('paused')
+ if (persist && activeState) persistStatePlayback({ stateId: activeState, playing: false })
+ }
+
+ const stopState = (persist = true) => {
+ clearStateTimers()
+ playbackTimeline.current = stopPlaybackTimeline(playbackTimeline.current)
+ activeSequenceTransition.current = null
+ pausedSequenceTransition.current = null
+ stopTransition(true)
+ blinkControls.current?.stop()
+ blinkAnimating.current = false
+ blinkValue.jump(1)
+ paintPose(displayedPose.current, 1)
+ setStatePlaying(false)
+ setPlaybackStatus('stopped')
+ setPlaybackVisual(current => ({ ...current, position: null }))
+ if (persist) persistStatePlayback({ stateId: null, playing: false })
+ }
+
+ const launchSequence = (sequence: AvatarSequence, resume = false, persist = true) => {
+ clearStateTimers()
+ if (!sequence.steps.length) {
+ stopState(persist)
+ return
+ }
+ const id = sequence.id
+ playbackTimeline.current = beginPlayback(playbackTimeline.current, resume)
+ setSelectedState(id)
+ setActiveState(id)
+ activeSequenceRef.current = sequence
+ setStatePlaying(true)
+ setPlaybackStatus('playing')
+ if (persist) persistStatePlayback({ stateId: id, playing: true })
+ const scheduleAdvance = (delay: number) => {
+ playbackTimeline.current = schedulePlaybackStep(
+ playbackTimeline.current,
+ readSequenceClock(),
+ delay
+ )
+ stateTimer.current = setTimeout(advance, delay)
+ }
+ const playCurrentStep = () => {
+ playbackTimeline.current = { ...playbackTimeline.current, stepDueAt: null }
+ const step = sequence.steps[playbackTimeline.current.position]
+ const availableExpressions = expressionsRef.current
+ const expressionIndex = findExpressionIndex(availableExpressions, step.expressionId)
+ const preset = availableExpressions[expressionIndex]
+ const durationMs = (reduceMotion ? 0 : step.transitionMs) + step.holdMs
+ setPlaybackVisual(current => ({
+ position: playbackTimeline.current.position,
+ run: current.run + 1,
+ durationMs,
+ }))
+ if (preset) {
+ transitionToExpression(preset, expressionIndex, step)
+ }
+ scheduleAdvance(durationMs)
+ }
+ const advance = () => {
+ const advanced = advancePlaybackTimeline(playbackTimeline.current, sequence)
+ playbackTimeline.current = advanced.timeline
+ const { cursor } = advanced
+ if (cursor.complete) {
+ if (blinkTimer.current) clearTimeout(blinkTimer.current)
+ blinkTimer.current = null
+ playbackTimeline.current = { ...playbackTimeline.current, blinkDueAt: null }
+ setStatePlaying(false)
+ setPlaybackStatus('stopped')
+ setPlaybackVisual(current => ({ ...current, position: null }))
+ if (persist) persistStatePlayback({ stateId: null, playing: false })
+ return
+ }
+ playCurrentStep()
+ }
+ const scheduleBlink = (delay: number) => {
+ playbackTimeline.current = schedulePlaybackBlink(
+ playbackTimeline.current,
+ readSequenceClock(),
+ delay
+ )
+ blinkTimer.current = setTimeout(blinkLoop, delay)
+ }
+ const blinkLoop = () => {
+ playbackTimeline.current = { ...playbackTimeline.current, blinkDueAt: null }
+ blink(sequence.blink.durationMs)
+ const { minIntervalMs, maxIntervalMs } = sequence.blink
+ scheduleBlink(
+ sequence.blink.durationMs + minIntervalMs + Math.random() * (maxIntervalMs - minIntervalMs)
+ )
+ }
+ if (resume) {
+ const pausedTransition = pausedSequenceTransition.current
+ if (pausedTransition) {
+ transitionToExpression(pausedTransition.target, pausedTransition.index, {
+ ...pausedTransition.settings,
+ transitionMs: pausedTransition.remainingMs,
+ })
+ pausedSequenceTransition.current = null
+ }
+ if (blinkAnimating.current) blinkControls.current?.play()
+ const currentStep = sequence.steps[playbackTimeline.current.position]
+ scheduleAdvance(
+ playbackTimeline.current.stepRemainingMs ||
+ (reduceMotion ? 0 : currentStep.transitionMs) + currentStep.holdMs
+ )
+ } else {
+ playCurrentStep()
+ }
+ if (sequence.blink.enabled) {
+ scheduleBlink(
+ resume
+ ? playbackTimeline.current.blinkRemainingMs || sequence.blink.initialDelayMs
+ : sequence.blink.initialDelayMs
+ )
+ }
+ }
+
+ const toggleStatePlayback = () => {
+ if (!activeState || !activeSequenceRef.current) return
+ if (statePlaying) pauseState()
+ else launchSequence(activeSequenceRef.current, playbackStatus === 'paused')
+ }
+
+ const suspendStateForEditor = () => {
+ if (editorStateSnapshot.current || !activeState) return
+ editorStateSnapshot.current = {
+ stateId: activeState,
+ playing: statePlaying,
+ expression: { ...displayedPose.current.expression },
+ }
+ if (statePlaying) pauseState(false)
+ setActiveState(null)
+ }
+
+ const restoreStateAfterEditor = (availableSequences = sequences) => {
+ const snapshot = editorStateSnapshot.current
+ editorStateSnapshot.current = null
+ if (!snapshot) return
+ const sequence = availableSequences.find(item => item.id === snapshot.stateId)
+ if (!sequence) {
+ stopState(false)
+ persistStatePlayback({ stateId: null, playing: false })
+ return
+ }
+ activeSequenceRef.current = sequence
+ setSelectedState(sequence.id)
+ setActiveState(sequence.id)
+ if (snapshot.playing) {
+ launchSequence(sequence, true, false)
+ return
+ }
+ setStatePlaying(false)
+ transitionToExpression(snapshot.expression)
+ }
+
+ useEffect(() => {
+ if (initialStatePlaybackApplied.current) return
+ initialStatePlaybackApplied.current = true
+ const sequence = sequences.find(item => item.id === selectedState)
+ if (sequence && initialStatePlayback.stateId !== null) {
+ activeSequenceRef.current = sequence
+ setActiveState(sequence.id)
+ if (initialStatePlayback.playing) launchSequence(sequence, false, false)
+ else setPlaybackStatus('paused')
+ }
+ return () => {
+ initialStatePlaybackApplied.current = false
+ }
+ }, [])
+
+ const saveEditing = () => {
+ if (!editing) return
+ const index = editing.index ?? expressions.length
+ const savedDraft =
+ editing.index === null ? { ...editing.draft, id: createExpressionId() } : editing.draft
+ const next =
+ editing.index === null
+ ? [...expressions, savedDraft]
+ : expressions.map((item, itemIndex) =>
+ itemIndex === editing.index ? { ...savedDraft } : item
+ )
+ setExpressions(next)
+ updateStudioExpressions(next)
+ setEditing(null)
+ transitionToExpression(savedDraft, index)
+ if (!sequenceEditing) restoreStateAfterEditor()
+ }
+
+ const duplicateExpression = (_index: number | null, draft: Expression, editDuplicate = false) => {
+ const duplicate = { ...draft, id: createExpressionId() }
+ const next = [...expressions, duplicate]
+ const duplicateIndex = next.length - 1
+ setExpressions(next)
+ updateStudioExpressions(next)
+ if (editDuplicate) openExpressionEditor(duplicateIndex, duplicate)
+ else transitionToExpression(duplicate, duplicateIndex)
+ }
+
+ const previewExpressionMove = (targetId: string | null) => {
+ const draggedId = draggedExpressionId.current
+ if (!draggedId || draggedId === targetId) return
+ const current = expressionDragPreview.current
+ const dragged = current.find(item => item.id === draggedId)
+ if (!dragged) return
+ const activeId = activeExpression === null ? null : current[activeExpression]?.id
+ const next = current.filter(item => item.id !== draggedId)
+ const targetIndex = targetId ? next.findIndex(item => item.id === targetId) : next.length
+ next.splice(targetIndex < 0 ? next.length : targetIndex, 0, dragged)
+ expressionDragPreview.current = next
+ setExpressions(next)
+ if (activeId) setActiveExpression(next.findIndex(item => item.id === activeId))
+ }
+
+ const commitExpressionMove = (targetId: string | null) => {
+ previewExpressionMove(targetId)
+ updateStudioExpressions(expressionDragPreview.current)
+ expressionDragOrigin.current = null
+ draggedExpressionId.current = null
+ setDraggingExpressionId(null)
+ }
+
+ const cancelExpressionMove = () => {
+ if (draggedExpressionId.current && expressionDragOrigin.current) {
+ expressionDragPreview.current = expressionDragOrigin.current
+ setExpressions(expressionDragOrigin.current)
+ }
+ expressionDragOrigin.current = null
+ draggedExpressionId.current = null
+ setDraggingExpressionId(null)
+ }
+
+ const previewExpressionDraft = (draft: Expression) => {
+ setEditing(current => (current ? { ...current, draft } : current))
+ const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
+ if (avatar) setDisplayColors(resolveColors(draft, avatar.colors))
+ paintPose(poseFromExpression(draft))
+ }
+
+ const previewCanvasExpression = (next: Expression, target: CanvasPreviewTarget) => {
+ const now = performance.now()
+ const updateInspector = now - lastInspectorFrame.current >= INSPECTOR_FRAME_MS
+ if (updateInspector) {
+ lastInspectorFrame.current = now
+ if (editing) {
+ setEditing(current => (current ? { ...current, draft: next } : current))
+ } else {
+ setExpression(next)
+ }
+ }
+ if (bodyEditing) {
+ paintRenderedRotationGizmo(renderedRotationGizmo, next)
+ paintRenderedScene(
+ renderedScene,
+ renderAvatar(
+ poseFromExpression(
+ resolveCanvasPreviewExpression(next, activeAvatarEyes, bodyEditing, target)
+ ),
+ surfaceRef.current,
+ blinkValue.get(),
+ {
+ includeWire: showWireRef.current || highlightRef.current === 'head',
+ bodyNodes: bodyNodesRef.current,
+ }
+ )
+ )
+ return
+ }
+ const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
+ if (avatar) setDisplayColors(resolveColors(next, avatar.colors))
+ paintPose(poseFromExpression(next))
+ }
+
+ const openExpressionEditor = (index: number | null, draft: Expression) => {
+ suspendStateForEditor()
+ setBodyEditing(false)
+ setMode('expressions')
+ setEditing({
+ index,
+ draft: { ...draft, id: index === null ? createExpressionId() : draft.id },
+ })
+ const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
+ if (avatar) setDisplayColors(resolveColors(draft, avatar.colors))
+ paintPose(poseFromExpression(draft))
+ }
+
+ const cancelExpressionEditing = () => {
+ setEditing(null)
+ const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
+ if (avatar) setDisplayColors(resolveColors(expression, avatar.colors))
+ paintPose(poseFromExpression(expression))
+ if (!sequenceEditing) restoreStateAfterEditor()
+ }
+
+ const deleteEditing = () => {
+ if (editing?.index === null || editing?.index === undefined) return
+ const next =
+ expressions.length <= 1
+ ? [{ ...defaultExpression }]
+ : expressions.filter((_, index) => index !== editing.index)
+ const fallback = next[Math.min(editing.index, next.length - 1)] ?? defaultExpression
+ const deletedExpressionId = expressions[editing.index]?.id ?? editing.draft.id
+ const fallbackExpressionId = fallback.id
+ const nextSequences = remapSequencesAfterExpressionDelete(
+ sequences,
+ deletedExpressionId,
+ fallbackExpressionId
+ )
+ setExpressions(next)
+ setSequences(nextSequences)
+ if (sequenceEditing) {
+ const draft = remapSequencesAfterExpressionDelete(
+ [sequenceEditing.draft],
+ deletedExpressionId,
+ fallbackExpressionId
+ )[0]
+ setSequenceEditing({ ...sequenceEditing, draft })
+ }
+ updateStudioExpressions(next)
+ updateStudioSequences(nextSequences)
+ setActiveExpression(null)
+ setEditing(null)
+ setDeleteExpressionOpen(false)
+ setExpression(fallback)
+ const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
+ if (avatar) setDisplayColors(resolveColors(fallback, avatar.colors))
+ paintPose(poseFromExpression(fallback))
+ if (!sequenceEditing) restoreStateAfterEditor()
+ }
+
+ const openSequenceEditor = (sequence?: AvatarSequence) => {
+ suspendStateForEditor()
+ setEditing(null)
+ setBodyEditing(false)
+ setMode('states')
+ const draft = sequence
+ ? {
+ ...sequence,
+ steps: sequence.steps.map(step => ({ ...step })),
+ blink: { ...sequence.blink },
+ }
+ : createSequence(expressions[activeExpression ?? 0]?.id ?? expressions[0]?.id)
+ setSequenceEditing({ sourceId: sequence?.id ?? null, draft })
+ setSelectedSequenceStepId(draft.steps[0]?.id ?? null)
+ const firstStep = draft.steps[0]
+ const expressionIndex = firstStep
+ ? findExpressionIndex(expressions, firstStep.expressionId)
+ : -1
+ const preset = expressions[expressionIndex]
+ if (preset) transitionToExpression(preset, expressionIndex, firstStep)
+ }
+
+ const cancelSequenceEditing = () => {
+ if (activeState === sequenceEditing?.draft.id) stopState(false)
+ setSequenceEditing(null)
+ setSelectedSequenceStepId(null)
+ restoreStateAfterEditor()
+ }
+
+ const saveSequenceEditing = () => {
+ if (!sequenceEditing?.draft.steps.length || !sequenceEditing.draft.name.trim()) return
+ const saved = {
+ ...sequenceEditing.draft,
+ name: sequenceEditing.draft.name.trim(),
+ group: sequenceEditing.draft.group.trim() || 'Custom',
+ }
+ const next = sequenceEditing.sourceId
+ ? sequences.map(sequence => (sequence.id === sequenceEditing.sourceId ? saved : sequence))
+ : [...sequences, saved]
+ setSequences(next)
+ updateStudioSequences(next)
+ setSelectedState(saved.id)
+ if (activeState === saved.id) stopState(false)
+ setSequenceEditing(null)
+ setSelectedSequenceStepId(null)
+ restoreStateAfterEditor(next)
+ }
+
+ const duplicateSequenceEditing = () => {
+ if (!sequenceEditing) return
+ if (activeState === sequenceEditing.draft.id) stopState(false)
+ const duplicate = duplicateSequence(sequenceEditing.draft)
+ const next = [...sequences, duplicate]
+ setSequences(next)
+ updateStudioSequences(next)
+ setSelectedState(duplicate.id)
+ setSequenceEditing({ sourceId: duplicate.id, draft: duplicate })
+ setSelectedSequenceStepId(duplicate.steps[0]?.id ?? null)
+ }
+
+ const duplicateState = (sequence: AvatarSequence) => {
+ if (activeState === sequence.id) stopState(false)
+ const duplicate = duplicateSequence(sequence)
+ const next = [...sequences, duplicate]
+ setSequences(next)
+ updateStudioSequences(next)
+ setSelectedState(duplicate.id)
+ }
+
+ const previewStateMove = (targetId: string | null, targetGroup: string) => {
+ const draggedId = draggedStateId.current
+ if (!draggedId || draggedId === targetId) return
+ const current = stateDragPreview.current
+ const dragged = current.find(sequence => sequence.id === draggedId)
+ if (!dragged) return
+ const next = current.filter(sequence => sequence.id !== draggedId)
+ const moved = { ...dragged, group: targetGroup }
+ if (targetId) {
+ const targetIndex = next.findIndex(sequence => sequence.id === targetId)
+ next.splice(targetIndex < 0 ? next.length : targetIndex, 0, moved)
+ } else {
+ let lastGroupIndex = -1
+ for (let index = next.length - 1; index >= 0; index -= 1) {
+ if (next[index].group !== targetGroup) continue
+ lastGroupIndex = index
+ break
+ }
+ next.splice(lastGroupIndex + 1, 0, moved)
+ }
+ stateDragPreview.current = next
+ setSequences(next)
+ }
+
+ const commitStateMove = (targetId: string | null, targetGroup: string) => {
+ previewStateMove(targetId, targetGroup)
+ updateStudioSequences(stateDragPreview.current)
+ stateDragOrigin.current = null
+ draggedStateId.current = null
+ setDraggingStateId(null)
+ }
+
+ const cancelStateMove = () => {
+ if (draggedStateId.current && stateDragOrigin.current) {
+ stateDragPreview.current = stateDragOrigin.current
+ setSequences(stateDragOrigin.current)
+ }
+ stateDragOrigin.current = null
+ draggedStateId.current = null
+ setDraggingStateId(null)
+ }
+
+ const deleteSequenceEditing = () => {
+ if (!sequenceEditing?.sourceId) return
+ const next = sequences.filter(sequence => sequence.id !== sequenceEditing.sourceId)
+ const fallback = next[0]
+ setSequences(next)
+ updateStudioSequences(next)
+ if (activeState === sequenceEditing.sourceId) stopState(false)
+ setSelectedState(fallback?.id ?? '')
+ setSequenceEditing(null)
+ setSelectedSequenceStepId(null)
+ setDeleteSequenceOpen(false)
+ restoreStateAfterEditor(next)
+ }
+
+ const selectedBodyNode =
+ selectedBodyNodeId === 'primary'
+ ? null
+ : (bodyNodes.find(node => node.id === selectedBodyNodeId) ?? null)
+
+ const updateNodeVector = (property: 'position' | 'rotation', index: 0 | 1 | 2, value: number) => {
+ updateSelectedBodyNode(node => {
+ const vector = [...node[property]] as [number, number, number]
+ vector[index] = value
+ return { ...node, [property]: vector }
+ })
+ }
+ const activeAvatar = avatars.find(avatar => avatar.id === activeAvatarId) ?? avatars[0]
+ const activeAvatarEyes = activeAvatar.eyes ?? defaultAvatarEyes
+ const activeSequence = sequences.find(sequence => sequence.id === activeState) ?? null
+ const activeSequenceLabel = activeSequence
+ ? activeSequence.builtIn
+ ? t(activeSequence.name)
+ : activeSequence.name
+ : null
+ const expressionById = new Map(expressions.map(item => [item.id, item]))
+ const exportAnimationIdSet = new Set(exportAnimationIds)
+ const selectedExportAnimations = sequences.filter(animation =>
+ exportAnimationIdSet.has(animation.id)
+ )
+ const toggleExportAnimation = (animationId: string) => {
+ setExportAnimationIds(current =>
+ current.includes(animationId)
+ ? current.filter(id => id !== animationId)
+ : [...current, animationId]
+ )
+ }
+ const downloadAvatarExport = () => {
+ if (!selectedExportAnimations.length) return
+ const payload = createAvatarExportPayload(activeAvatar, expressions, selectedExportAnimations)
+ const isReact = exportFormat === 'react'
+ const extension = 'zip'
+ const blob = isReact
+ ? generateReactAvatarPackage(payload)
+ : generateJavaScriptAvatarPackage(payload, language)
+ downloadBlob(blob, avatarExportFileName(activeAvatar.name, extension))
+ }
+ const currentStudioDocument = (): StudioDocument => ({
+ version: 2,
+ library: { activeAvatarId, avatars },
+ expressions: baseBehavior.expressions,
+ sequences: baseBehavior.sequences,
+ playback: {
+ stateId: playbackStatus === 'stopped' ? null : (activeState ?? (selectedState || null)),
+ playing: statePlaying,
+ },
+ })
+ const downloadStudioProject = () => {
+ const blob = new Blob([serializeStudioDocument(currentStudioDocument())], {
+ type: 'application/json',
+ })
+ downloadBlob(blob, 'avatar-studio-project.json')
+ }
+ const currentSnapshotSvg = () =>
+ serializeAvatarSnapshot(
+ activeAvatar.name,
+ renderedScene,
+ {
+ body: renderedColors.body.get(),
+ eyes: renderedColors.eyes.get(),
+ },
+ {
+ background: snapshotBackground,
+ colorFrom: snapshotColorFrom,
+ colorTo: snapshotColorTo,
+ size: Number(snapshotSize),
+ }
+ )
+
+ const downloadSnapshotSvg = () => {
+ downloadBlob(
+ new Blob([currentSnapshotSvg()], { type: 'image/svg+xml;charset=utf-8' }),
+ snapshotFileName(activeAvatar.name)
+ )
+ }
+ const downloadSnapshotPng = () => {
+ const size = Number(snapshotSize)
+ const source = new Blob([currentSnapshotSvg()], { type: 'image/svg+xml;charset=utf-8' })
+ const sourceUrl = URL.createObjectURL(source)
+ const image = new Image()
+ image.onload = () => {
+ const canvas = document.createElement('canvas')
+ canvas.width = size
+ canvas.height = size
+ canvas.getContext('2d')?.drawImage(image, 0, 0, size, size)
+ URL.revokeObjectURL(sourceUrl)
+ canvas.toBlob(blob => {
+ if (blob) downloadBlob(blob, snapshotFileName(activeAvatar.name, 'png'))
+ }, 'image/png')
+ }
+ image.onerror = () => URL.revokeObjectURL(sourceUrl)
+ image.src = sourceUrl
+ }
+ const takePicture = () => {
+ setPhotoFlash(current => current + 1)
+ requestAnimationFrame(() => {
+ if (snapshotFormat === 'png') downloadSnapshotPng()
+ else downloadSnapshotSvg()
+ })
+ }
+ const prepareStudioProjectImport = (file: File | undefined) => {
+ if (!file) return
+ setProjectImportError(null)
+ if (file.size > 10_000_000) {
+ setProjectImportError(
+ t('Ce fichier ne contient pas un projet Avatar Studio valide et compatible.')
+ )
+ return
+ }
+ file
+ .text()
+ .then(source => {
+ const imported = parseImportedStudioDocument(source, currentStudioDocument())
+ setPendingProjectImport({ document: imported, fileName: file.name })
+ })
+ .catch(() => {
+ setProjectImportError(
+ t('Ce fichier ne contient pas un projet Avatar Studio valide et compatible.')
+ )
+ })
+ }
+ const confirmStudioProjectImport = () => {
+ if (!pendingProjectImport) return
+ if (!persistStudioDocument(pendingProjectImport.document)) {
+ setProjectImportError(
+ t(
+ 'Le projet n’a pas pu être enregistré dans ce navigateur. Libère de l’espace puis réessaie.'
+ )
+ )
+ setPendingProjectImport(null)
+ return
+ }
+ window.location.reload()
+ }
+ const canvasExpression = editing?.draft ?? expression
+ const editorPageOpen = bodyEditing || editing !== null || sequenceEditing !== null
+ const expressionPendingDeletionId =
+ editing?.index !== null && editing?.index !== undefined
+ ? (expressions[editing.index]?.id ?? editing.draft.id)
+ : null
+ const animationsAffectedByExpressionDeletion = expressionPendingDeletionId
+ ? sequences.filter(sequence =>
+ sequence.steps.some(step => step.expressionId === expressionPendingDeletionId)
+ )
+ : []
+
+ useEffect(() => {
+ if (!editorPageOpen || focusAvatarName) return
+ const frame = requestAnimationFrame(() => workspaceBackButtonRef.current?.focus())
+ return () => cancelAnimationFrame(frame)
+ }, [editorPageOpen, focusAvatarName])
+
+ const updateAvatarEyeDimension = (side: Side, dimension: 'width' | 'height', value: number) => {
+ const next = updateEyeDimension(
+ { ...defaultExpression, ...activeAvatarEyes },
+ side,
+ dimension,
+ value,
+ linked[dimension]
+ )
+ updateAvatarEyes({
+ [`${dimension}Left`]: next[`${dimension}Left`],
+ [`${dimension}Right`]: next[`${dimension}Right`],
+ })
+ }
+ const updateAvatarEyeSize = (side: Side, value: number) => {
+ const next = scaleEye({ ...defaultExpression, ...activeAvatarEyes }, side, value, linked.size)
+ updateAvatarEyes({
+ widthLeft: next.widthLeft,
+ widthRight: next.widthRight,
+ heightLeft: next.heightLeft,
+ heightRight: next.heightRight,
+ })
+ }
+ const updateAvatarEyePosition = (side: Side, axis: 'X' | 'Y', value: number) => {
+ const next = updateEyePosition(
+ { ...defaultExpression, ...activeAvatarEyes },
+ side,
+ axis,
+ value,
+ linked.position
+ )
+ updateAvatarEyes({
+ [`position${axis}Left`]: next[`position${axis}Left`],
+ [`position${axis}Right`]: next[`position${axis}Right`],
+ })
+ }
+ const persistEditedEyeExpression = (next: Expression) => {
+ updateAvatarEyes({
+ widthLeft: next.widthLeft,
+ widthRight: next.widthRight,
+ heightLeft: next.heightLeft,
+ heightRight: next.heightRight,
+ spacing: next.spacing,
+ positionXLeft: next.positionXLeft,
+ positionXRight: next.positionXRight,
+ positionYLeft: next.positionYLeft,
+ positionYRight: next.positionYRight,
+ leftAngle: next.leftAngle,
+ rightAngle: next.rightAngle,
+ })
+ }
+
+ return (
+
+
+
+
+ Bible Strong Avatar Lab
+
+
+
+ {language === 'en' ? '🇬🇧' : language === 'fr' ? '🇫🇷' : '🇨🇳'}
+
+ next && setLanguage(next as StudioLanguage)}
+ >
+
+
+
+
+ English
+ Français
+ 中文
+
+
+
+ {
+ if (editing) {
+ setEditing(current => (current ? { ...current, draft: next } : current))
+ }
+ transitionToExpression(next)
+ }}
+ onEyeChange={
+ editing ? previewExpressionDraft : bodyEditing ? persistEditedEyeExpression : undefined
+ }
+ playback={
+ activeSequenceLabel && playbackStatus !== 'stopped'
+ ? { name: activeSequenceLabel, status: playbackStatus }
+ : null
+ }
+ onManipulationStart={freezeLivePreviewForManipulation}
+ />
+ {photoFlash > 0 && (
+
+ )}
+
+
+
+
+ {t('Prendre une photo')}
+ {snapshotFormat.toUpperCase()}
+
+
+
+ }
+ >
+
+
+
+ {t(
+ 'Tu peux modifier le format, le fond et la définition du mode photo dans Export.'
+ )}
+
+
+
+
+
+
+
+ {sequenceEditing && !editing && (
+
+
+ setSequenceEditing(current => (current ? { ...current, draft } : current))
+ }
+ onPreviewStep={step => {
+ const expressionIndex = findExpressionIndex(expressions, step.expressionId)
+ const preset = expressions[expressionIndex]
+ if (preset) transitionToExpression(preset, expressionIndex, step)
+ }}
+ onEditExpression={openExpressionEditor}
+ onPlay={() => launchSequence(sequenceEditing.draft, false, false)}
+ onPause={() => pauseState(false)}
+ onStop={() => stopState(false)}
+ playing={statePlaying}
+ active={activeState === sequenceEditing.draft.id}
+ onCancel={cancelSequenceEditing}
+ onSave={saveSequenceEditing}
+ onDuplicate={duplicateSequenceEditing}
+ onDelete={() => setDeleteSequenceOpen(true)}
+ />
+
+ )}
+ {editing && (
+
+ duplicateExpression(editing.index, editing.draft, true)}
+ onDelete={() => setDeleteExpressionOpen(true)}
+ />
+
+ )}
+ {!sequenceEditing &&
+ !editing &&
+ (bodyEditing ? (
+
+ ) : (
+ <>
+
+
+ Avatars
+ {t('Double-clic pour modifier')}
+
+
+ {avatars.map(avatar => (
+
+
+ {
+ avatarDragOrigin.current = avatarsRef.current
+ avatarDragPreview.current = avatarsRef.current
+ draggedAvatarId.current = avatar.id
+ setDraggingAvatarId(avatar.id)
+ event.dataTransfer.effectAllowed = 'move'
+ }}
+ onDragEnter={() => previewAvatarMove(avatar.id)}
+ onDragOver={event => {
+ event.preventDefault()
+ event.dataTransfer.dropEffect = 'move'
+ }}
+ onDrop={event => {
+ event.preventDefault()
+ commitAvatarMove(avatar.id)
+ }}
+ onDragEnd={cancelAvatarMove}
+ onClick={() => activateAvatar(avatar.id, false, true)}
+ onDoubleClick={() => {
+ setFocusAvatarName(false)
+ activateAvatar(avatar.id, true)
+ }}
+ >
+
+ {avatar.name}
+
+ }
+ />
+
+ {
+ setFocusAvatarName(false)
+ activateAvatar(avatar.id, true)
+ }}
+ >
+ {t('Modifier')}
+
+ duplicateAvatar(avatar)}>
+ {t('Dupliquer')}
+
+
+ {
+ activateAvatar(avatar.id, false, true)
+ setDeleteAvatarOpen(true)
+ }}
+ >
+ {t('Supprimer')}
+
+
+
+
+ ))}
+
+
+
+
+
+ setMode(value as Mode)}>
+
+ {t('Pose')}
+ {t('Expressions')}
+ {t('Animations')}
+ {t('Exporter')}
+
+
+ >
+ ))}
+
+ {!editing && mode === 'manual' && (
+
+ {bodyEditing && (
+ <>
+
+
+
+
+ selectBodyNode('primary')}
+ >
+
+
+
+
+ {t('Forme principale')}
+
+ {t(surfaceLabels[surface.type])} · {t('porte les yeux')}
+
+
+
+ {bodyNodes.map(node => (
+ selectBodyNode(node.id)}
+ >
+
+
+
+
+ {t(node.name)}
+ {t(surfaceLabels[node.surface.type])}
+
+
+ ))}
+
+
+
+ {t('Ajouter une forme')} · {bodyNodes.length}/{MAX_BODY_NODES}
+
+
+ {bodyPrimitiveTypes.map(type => (
+ = MAX_BODY_NODES}
+ onClick={() => addBodyNode(type)}
+ >
+
+ {t(surfaceLabels[type])}
+
+ ))}
+
+
+ {selectedBodyNode && (
+
+
+
{selectedBodyNode.name}
+
+ = MAX_BODY_NODES}
+ onClick={duplicateSelectedBodyNode}
+ >
+ {t('Dupliquer')}
+
+
+ {t('Supprimer')}
+
+
+
+
+ {t('Gizmo local')}
+ {t(
+ 'Glisse un axe pour déplacer la forme, ou un anneau pour la faire tourner.'
+ )}
+
+
+
+ updateSelectedBodyNode(node => ({
+ ...node,
+ surface: scaleSurface(node.surface, size, {
+ width: 10,
+ height: 10,
+ depth: 10,
+ }),
+ }))
+ }
+ />
+ {(['width', 'height', 'depth'] as const).map(dimension => (
+
+ updateSelectedBodyNode(node => ({
+ ...node,
+ surface: { ...node.surface, [dimension]: value },
+ }))
+ }
+ />
+ ))}
+ {(selectedBodyNode.surface.type === 'cube' ||
+ selectedBodyNode.surface.type === 'diamond' ||
+ selectedBodyNode.surface.type === 'cylinder') && (
+
+ updateSelectedBodyNode(node => ({
+ ...node,
+ surface: { ...node.surface, roundness },
+ }))
+ }
+ />
+ )}
+ {(selectedBodyNode.surface.type === 'cylinder' ||
+ selectedBodyNode.surface.type === 'cone') && (
+
+ updateSelectedBodyNode(node => ({
+ ...node,
+ surface: { ...node.surface, morphRoundness },
+ }))
+ }
+ />
+ )}
+ {selectedBodyNode.surface.type === 'cone' && (
+ <>
+
+ updateSelectedBodyNode(node => ({
+ ...node,
+ surface: { ...node.surface, tipRoundness },
+ }))
+ }
+ />
+
+ updateSelectedBodyNode(node => ({
+ ...node,
+ surface: { ...node.surface, baseRoundness },
+ }))
+ }
+ />
+ >
+ )}
+
+
+
+
{t('Position locale')}
+ {(['X', 'Y', 'Z'] as const).map((axis, index) => (
+
+ updateNodeVector('position', index as 0 | 1 | 2, value)
+ }
+ />
+ ))}
+
+
+
{t('Rotation locale')}
+ {(['X', 'Y', 'Z'] as const).map((axis, index) => (
+
+ updateNodeVector('rotation', index as 0 | 1 | 2, value)
+ }
+ />
+ ))}
+
+
+
+ )}
+
+
+
+
+ {bodyPrimitiveTypes.map(type => {
+ const previewSurface =
+ type === surface.type ? surface : surfacePresets[type]
+ return (
+ {
+ selectBodyNode('primary')
+ if (type !== surface.type) {
+ updateSurface({ ...surfacePresets[type] })
+ }
+ }}
+ >
+
+ {t(surfaceLabels[type])}
+
+ )
+ })}
+
+
+
+ updateSurface(
+ scaleSurface(surface, size, { width: 120, height: 120, depth: 100 })
+ )
+ }
+ />
+ updateSurface({ ...surface, width })}
+ />
+ updateSurface({ ...surface, height })}
+ />
+ updateSurface({ ...surface, depth })}
+ />
+ {(surface.type === 'cube' || surface.type === 'diamond') && (
+ updateHighlight(active ? 'head' : null)}
+ onChange={roundness => updateSurface({ ...surface, roundness })}
+ />
+ )}
+ {surface.type === 'cylinder' && (
+ updateHighlight(active ? 'head' : null)}
+ onChange={roundness => updateSurface({ ...surface, roundness })}
+ />
+ )}
+ {(surface.type === 'cylinder' || surface.type === 'cone') && (
+ updateHighlight(active ? 'head' : null)}
+ onChange={morphRoundness => updateSurface({ ...surface, morphRoundness })}
+ />
+ )}
+ {surface.type === 'cone' && (
+ <>
+ updateHighlight(active ? 'head' : null)}
+ onChange={tipRoundness => updateSurface({ ...surface, tipRoundness })}
+ />
+ updateHighlight(active ? 'head' : null)}
+ onChange={baseRoundness => updateSurface({ ...surface, baseRoundness })}
+ />
+ >
+ )}
+
+
+
+
+ updateAvatarColors({ body })}
+ />
+
+
+
+
+ {t(
+ 'Définis l’identité du regard de cet avatar. Les poses s’ajoutent ensuite à cette base.'
+ )}
+
+ {(['width', 'height', 'size'] as const).map(dimension => (
+
+
+
+ {t(
+ {
+ width: 'Largeur',
+ height: 'Hauteur',
+ size: 'Taille proportionnelle',
+ }[dimension]
+ )}
+
+
+ setLinked(current => ({
+ ...current,
+ [dimension]: !current[dimension],
+ }))
+ }
+ />
+
+
+ {(['Left', 'Right'] as Side[]).map(side => {
+ const width = activeAvatarEyes[`width${side}`]
+ const height = activeAvatarEyes[`height${side}`]
+ const value =
+ dimension === 'width'
+ ? width
+ : dimension === 'height'
+ ? height
+ : Math.max(width, height)
+ return (
+
+ updateHighlight(
+ active
+ ? linked[dimension]
+ ? 'both'
+ : side === 'Left'
+ ? 'left'
+ : 'right'
+ : null
+ )
+ }
+ onChange={next =>
+ dimension === 'size'
+ ? updateAvatarEyeSize(side, next)
+ : updateAvatarEyeDimension(side, dimension, next)
+ }
+ />
+ )
+ })}
+
+
+ ))}
+
+
+
+
{t('Position et espacement')}
+
+ {t('Coordonnées propres à l’avatar, indépendantes des poses.')}
+
+
+
+ setLinked(current => ({ ...current, position: !current.position }))
+ }
+ />
+
+
+ {(['Left', 'Right'] as Side[]).map(side => (
+
+
{t(side === 'Left' ? 'Œil gauche' : 'Œil droit')}
+
+ updateHighlight(
+ active
+ ? linked.position
+ ? 'both'
+ : side === 'Left'
+ ? 'left'
+ : 'right'
+ : null
+ )
+ }
+ onChange={value => updateAvatarEyePosition(side, 'X', value)}
+ />
+
+ updateHighlight(
+ active
+ ? linked.position
+ ? 'both'
+ : side === 'Left'
+ ? 'left'
+ : 'right'
+ : null
+ )
+ }
+ onChange={value => updateAvatarEyePosition(side, 'Y', value)}
+ />
+
+ ))}
+
+
+ updateHighlight(active ? 'both' : null)}
+ onChange={spacing => updateAvatarEyes({ spacing })}
+ />
+
+
+
+
+
+
+ setLinked(current => ({ ...current, rotation: !current.rotation }))
+ }
+ />
+
+
+
+ updateHighlight(active ? (linked.rotation ? 'both' : 'left') : null)
+ }
+ onChange={leftAngle =>
+ updateAvatarEyes({
+ leftAngle,
+ ...(linked.rotation ? { rightAngle: -leftAngle } : {}),
+ })
+ }
+ />
+
+ updateHighlight(active ? (linked.rotation ? 'both' : 'right') : null)
+ }
+ onChange={rightAngle =>
+ updateAvatarEyes({
+ rightAngle,
+ ...(linked.rotation ? { leftAngle: -rightAngle } : {}),
+ })
+ }
+ />
+
+
+
+
+ updateAvatarColors({ eyes })}
+ />
+
+
+ >
+ )}
+ {!bodyEditing && (
+ <>
+
+
+
+ updateImmediate({ ...expression, bodyColor })}
+ />
+ {expression.bodyColor && (
+ {
+ const next = { ...expression }
+ delete next.bodyColor
+ updateImmediate(next)
+ }}
+ >
+
+
+ )}
+
+
+
+ updateHighlight(active ? 'head' : null)}
+ onChange={value => updateImmediate({ ...expression, headX: value })}
+ />
+ updateHighlight(active ? 'head' : null)}
+ onChange={value => updateImmediate({ ...expression, headY: value })}
+ />
+ updateHighlight(active ? 'head' : null)}
+ onChange={value => updateImmediate({ ...expression, headZ: value })}
+ />
+
+
+
+
+
+ updateImmediate({ ...expression, eyeColor })}
+ />
+ {expression.eyeColor && (
+ {
+ const next = { ...expression }
+ delete next.eyeColor
+ updateImmediate(next)
+ }}
+ >
+
+
+ )}
+
+ {(['width', 'height', 'size'] as const).map(dimension => (
+
+
+
+ {t(
+ {
+ width: 'Largeur',
+ height: 'Hauteur',
+ size: 'Taille proportionnelle',
+ }[dimension]
+ )}
+
+
+ setLinked(current => ({
+ ...current,
+ [dimension]: !current[dimension],
+ }))
+ }
+ />
+
+
+ {(['Left', 'Right'] as Side[]).map(side => {
+ const width = expression[`width${side}`]
+ const height = expression[`height${side}`]
+ const value =
+ dimension === 'width'
+ ? width
+ : dimension === 'height'
+ ? height
+ : Math.max(width, height)
+ return (
+
+ updateHighlight(
+ active
+ ? linked[dimension]
+ ? 'both'
+ : side === 'Left'
+ ? 'left'
+ : 'right'
+ : null
+ )
+ }
+ onChange={next =>
+ dimension === 'size'
+ ? updateSize(side, next)
+ : updateDimension(side, dimension, next)
+ }
+ />
+ )
+ })}
+
+
+ ))}
+
+
+
+
+
{t('Œil gauche')}
+ updateHighlight(active ? 'left' : null)}
+ onChange={value =>
+ updateImmediate({ ...expression, positionXLeft: value })
+ }
+ />
+ updateHighlight(active ? 'left' : null)}
+ onChange={value =>
+ updateImmediate({ ...expression, positionYLeft: value })
+ }
+ />
+
+
+
{t('Œil droit')}
+ updateHighlight(active ? 'right' : null)}
+ onChange={value =>
+ updateImmediate({ ...expression, positionXRight: value })
+ }
+ />
+ updateHighlight(active ? 'right' : null)}
+ onChange={value =>
+ updateImmediate({ ...expression, positionYRight: value })
+ }
+ />
+
+
+
+ updateHighlight(active ? 'both' : null)}
+ onChange={updateSpacing}
+ />
+
+
+
+
+
+
+ setLinked(current => ({ ...current, rotation: !current.rotation }))
+ }
+ />
+
+
+
+ updateHighlight(active ? (linked.rotation ? 'both' : 'left') : null)
+ }
+ onChange={value =>
+ updateImmediate({
+ ...expression,
+ leftAngle: value,
+ ...(linked.rotation ? { rightAngle: -value } : {}),
+ })
+ }
+ />
+
+ updateHighlight(active ? (linked.rotation ? 'both' : 'right') : null)
+ }
+ onChange={value =>
+ updateImmediate({
+ ...expression,
+ rightAngle: value,
+ ...(linked.rotation ? { leftAngle: -value } : {}),
+ })
+ }
+ />
+
+
+
+
+
+
+ updateImmediate({ ...expression, perspective: value })}
+ />
+
+ {t('Afficher le maillage')}
+
+
+ transitionToExpression({ ...defaultExpression })}
+ >
+ {t('Réinitialiser')}
+
+
+
+ >
+ )}
+
+ )}
+
+ {!sequenceEditing && !editing && bodyEditing && (
+
+ )}
+
+ {!sequenceEditing && !editing && !bodyEditing && mode === 'expressions' && (
+
+
+
+
+
{expressions.length} presets
+
{t('Expressions')}
+
+
{t('Double-clic pour modifier')}
+
+
+ {expressions.map((preset, index) => (
+
+ transitionToExpression(preset, index)}
+ onEdit={() => openExpressionEditor(index, preset)}
+ onDuplicate={() => duplicateExpression(index, preset)}
+ onDelete={() => {
+ openExpressionEditor(index, preset)
+ setDeleteExpressionOpen(true)
+ }}
+ draggable
+ onDragStart={event => {
+ expressionDragOrigin.current = expressions
+ expressionDragPreview.current = expressions
+ draggedExpressionId.current = preset.id
+ setDraggingExpressionId(preset.id)
+ event.dataTransfer.effectAllowed = 'move'
+ }}
+ onDragEnter={() => previewExpressionMove(preset.id)}
+ onDragOver={event => {
+ event.preventDefault()
+ event.dataTransfer.dropEffect = 'move'
+ }}
+ onDrop={event => {
+ event.preventDefault()
+ commitExpressionMove(preset.id)
+ }}
+ onDragEnd={cancelExpressionMove}
+ />
+
+ ))}
+
event.preventDefault()}
+ onDrop={event => {
+ event.preventDefault()
+ commitExpressionMove(null)
+ }}
+ onClick={() => openExpressionEditor(null, expression)}
+ aria-label={t('Nouvelle expression')}
+ >
+
+
+
+
+
+
+ {
+ springSpeedRef.current = value
+ setSpringSpeed(value)
+ }}
+ />
+
+ blink()}>
+ {t('Cligner')}
+
+ {
+ const index = Math.floor(Math.random() * expressions.length)
+ transitionToExpression(expressions[index], index)
+ }}
+ >
+ {t('Expression aléatoire')}
+
+
+
+
+ )}
+
+ {!sequenceEditing && !editing && !bodyEditing && mode === 'states' && (
+
+
+
+
+
+ {sequences.length} {t('animations')}
+
+
{t('Animations')}
+
+
+
+ {groupSequences(sequences).map(group => (
+
event.preventDefault()}
+ onDrop={event => {
+ event.preventDefault()
+ commitStateMove(null, group.name)
+ }}
+ >
+
+ {group.sequences.every(sequence => sequence.builtIn)
+ ? t(group.name)
+ : group.name}
+
+
+ {group.sequences.map(sequence => {
+ const firstStep = sequence.steps[0]
+ const firstExpression = firstStep
+ ? expressionById.get(firstStep.expressionId)
+ : undefined
+ const card = (
+
{
+ stateDragOrigin.current = sequences
+ stateDragPreview.current = sequences
+ draggedStateId.current = sequence.id
+ setDraggingStateId(sequence.id)
+ event.dataTransfer.effectAllowed = 'move'
+ }}
+ onDragEnter={() => previewStateMove(sequence.id, group.name)}
+ onDragOver={event => {
+ event.preventDefault()
+ event.stopPropagation()
+ event.dataTransfer.dropEffect = 'move'
+ }}
+ onDrop={event => {
+ event.preventDefault()
+ event.stopPropagation()
+ commitStateMove(sequence.id, group.name)
+ }}
+ onDragEnd={cancelStateMove}
+ onClick={() => launchSequence(sequence)}
+ onDoubleClick={() => openSequenceEditor(sequence)}
+ >
+
+ {sequence.builtIn ? t(sequence.name) : sequence.name}
+
+ )
+ return (
+
+
+
+
+ openSequenceEditor(sequence)}>
+
+ {t('Modifier')}
+
+ duplicateState(sequence)}>
+
+ {t('Dupliquer')}
+
+
+ {
+ openSequenceEditor(sequence)
+ setDeleteSequenceOpen(true)
+ }}
+ >
+
+ {t('Supprimer')}
+
+
+
+
+ )
+ })}
+
+
+ ))}
+
+
openSequenceEditor()}
+ aria-label={t('Nouvelle animation')}
+ >
+
+
+
+
+
+
+ )}
+
+ {!sequenceEditing && !editing && !bodyEditing && mode === 'export' && (
+
+
+
+
+
+
+ {t('Avatar sélectionné')}
+ {activeAvatar.name}
+
+
+
+
+
+
+
+ setExportFormat('react')}
+ >
+
+
+ React / TypeScript
+ {t('Package React local (.zip)')}
+
+
+ setExportFormat('javascript')}
+ >
+
+
+ {t('Module JavaScript')}
+ {t('Projet HTML + module JS (.zip)')}
+
+
+
+
+
+
+
+
+
+ {selectedExportAnimations.length}/{sequences.length} {t('sélectionnées')}
+
+
{t('Animations à exporter')}
+
+
+ setExportAnimationIds(
+ selectedExportAnimations.length === sequences.length
+ ? []
+ : sequences.map(animation => animation.id)
+ )
+ }
+ >
+ {t(
+ selectedExportAnimations.length === sequences.length
+ ? 'Tout désélectionner'
+ : 'Tout sélectionner'
+ )}
+
+
+
+ {sequences.map(animation => {
+ const firstStep = animation.steps[0]
+ const firstExpression = firstStep
+ ? expressionById.get(firstStep.expressionId)
+ : undefined
+ return (
+ toggleExportAnimation(animation.id)}
+ >
+
+ {animation.builtIn ? t(animation.name) : animation.name}
+
+ )
+ })}
+
+
+
+
+
+ {t(
+ exportFormat === 'react'
+ ? 'Télécharger le package React'
+ : 'Télécharger le module'
+ )}
+
+
+
+
+
+
+
+ {t('Aperçu du mode photo')}
+ {activeAvatar.name}
+
+ {snapshotSize} × {snapshotSize} px · {snapshotFormat.toUpperCase()}
+
+
+
+
+
+
+
+ {t('Style')}
+
+ next && setSnapshotBackground(next as SnapshotBackground)
+ }
+ >
+
+
+
+
+ {t('Transparent')}
+ {t('Uni')}
+ {t('Dégradé linéaire')}
+ {t('Dégradé radial')}
+
+
+
+ {snapshotBackground !== 'transparent' && (
+
+
+ {(snapshotBackground === 'linear' || snapshotBackground === 'radial') && (
+
+ )}
+
+ )}
+
+
+
+
+
+ {t('Format d’export')}
+ {t('Choisis le type de fichier généré par le mode photo.')}
+
+ next && setSnapshotFormat(next as SnapshotFormat)}
+ >
+
+
+
+
+ PNG
+ SVG
+
+
+
+
+
+
+ {t('Définition')}
+ {t('Dimensions du fichier exporté.')}
+
+ ({ value, label: `${value} px` }))}
+ onValueChange={next => next && setSnapshotSize(next)}
+ >
+
+
+
+
+ 512 px
+ 1024 px
+ 2048 px
+
+
+
+
+
+
+
+
+
+
+ {t('Télécharger le projet JSON')}
+
+ projectImportRef.current?.click()}
+ >
+
+ {t('Importer un projet JSON')}
+
+ {
+ prepareStudioProjectImport(event.currentTarget.files?.[0])
+ event.currentTarget.value = ''
+ }}
+ />
+
+ {projectImportError && (
+
+ {projectImportError}
+
+ )}
+
+
+ )}
+ {activeSequence && !editorPageOpen && (
+
+ setStatePlayerExpanded(expanded => !expanded)}
+ >
+ {statePlayerExpanded ? : }
+
+ {statePlayerExpanded && (
+
+
+
+
{t('Détails de l’animation')}
+
{activeSequenceLabel}
+
+ {activeSequence.builtIn
+ ? t(activeSequence.description)
+ : activeSequence.description}
+
+
+
+ {t('Mode de lecture')} · {t(activeSequence.playbackMode)}
+
+
+
+
+ {t('Expressions')}
+ {activeSequence.steps.length}
+
+ {activeSequence.steps
+ .map(step => formatSeconds(step.holdMs, language))
+ .join(' · ')}
+
+
+
+ {t('Premier clignement')}
+
+ {activeSequence.blink.enabled
+ ? formatSeconds(activeSequence.blink.initialDelayMs, language)
+ : t('Désactivé')}
+
+ {t('après le lancement')}
+
+
+ {t('Intervalle du clignement')}
+
+ {formatSeconds(activeSequence.blink.minIntervalMs, language)}–
+ {formatSeconds(activeSequence.blink.maxIntervalMs, language)}
+
+ {t('tirage aléatoire')}
+
+
+ {t('Durée du clignement')}
+ {activeSequence.blink.durationMs} ms
+ {t('fermeture et ouverture')}
+
+
+
+ )}
+
+
+ {activeSequence.steps.map((step, position) => {
+ const expressionIndex = findExpressionIndex(expressions, step.expressionId)
+ const preset = expressions[expressionIndex]
+ if (!preset) return null
+ return (
+ transitionToExpression(preset, expressionIndex, step)}
+ >
+
+ {playbackVisual.position === position && (
+
+ )}
+
+ )
+ })}
+
+
+
+
+
+
+ )}
+
+
+
+
+ {t(`Supprimer ${activeAvatar.name} ?`)}
+
+ {t(
+ 'Le corps, les expressions et les animations propres à cet avatar seront définitivement supprimés. La bibliothèque de base sera conservée.'
+ )}
+
+
+
+ {t('Annuler')}
+ {t('Supprimer')}
+
+
+
+
+
+
+ {t('Supprimer cette expression ?')}
+
+ {t(
+ 'Cette action retirera définitivement le preset de la bibliothèque de cet avatar.'
+ )}
+
+ {animationsAffectedByExpressionDeletion.length > 0 && (
+
+
+ {t('Cette expression sera aussi retirée des animations suivantes :')}
+
+
+ {animationsAffectedByExpressionDeletion.map(sequence => (
+
+ {sequence.name}
+
+ ))}
+
+
+ {t(
+ 'Si une animation ne contient que cette expression, l’expression de repli lui sera assignée pour qu’elle reste jouable.'
+ )}
+
+
+ )}
+
+
+ {t('Annuler')}
+ {t('Supprimer')}
+
+
+
+
+
+
+ {t('Supprimer cette animation ?')}
+
+ {t('Cette action supprimera définitivement cette animation.')}
+
+
+
+ {t('Annuler')}
+ {t('Supprimer')}
+
+
+
+
{
+ if (!open) setPendingProjectImport(null)
+ }}
+ >
+
+
+ {t('Importer ce projet ?')}
+
+ {t(
+ 'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.'
+ )}{' '}
+ {pendingProjectImport?.fileName}
+
+
+
+ {t('Annuler')}
+
+ {t('Importer')}
+
+
+
+
+
+ )
+}
+
+function ControlSection({
+ title,
+ subtitle,
+ compact = false,
+ children,
+}: {
+ title: string
+ subtitle: string
+ compact?: boolean
+ children: React.ReactNode
+}) {
+ const { t } = useStudioLanguage()
+ return (
+
+
+ {t(title)}
+ {t(subtitle)}
+
+
+ {children}
+
+ )
+}
+
+function SnapshotPreview({
+ scene,
+ colors,
+ background,
+ colorFrom,
+ colorTo,
+}: {
+ scene: RenderedScene
+ colors: RenderedColors
+ background: SnapshotBackground
+ colorFrom: string
+ colorTo: string
+}) {
+ const id = useId().replace(/:/g, '')
+ const clipId = `${id}-clip`
+ const linearId = `${id}-linear`
+ const radialId = `${id}-radial`
+ const backgroundFill =
+ background === 'solid'
+ ? colorFrom
+ : background === 'linear'
+ ? `url(#${linearId})`
+ : `url(#${radialId})`
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {background !== 'transparent' && (
+
+ )}
+
+ {scene.backPaths.map((pathValue, index) => (
+
+ ))}
+
+
+
+
+
+ {scene.frontPaths.map((pathValue, index) => (
+
+ ))}
+
+
+
+ )
+}
+
+function ExportSection({
+ value,
+ title,
+ subtitle,
+ children,
+}: {
+ value: string
+ title: string
+ subtitle: string
+ children: React.ReactNode
+}) {
+ const { t } = useStudioLanguage()
+ return (
+
+
+
+ {t(title)}
+ {t(subtitle)}
+
+
+
+ {children}
+
+
+ )
+}
+
+function InspectorCard({ className, ...props }: React.ComponentProps) {
+ return
+}
+
+function StatePlayer({
+ name,
+ status,
+ onToggle,
+ onStop,
+}: {
+ name: string | null
+ status: PlaybackStatus
+ onToggle: () => void
+ onStop: () => void
+}) {
+ const { t } = useStudioLanguage()
+ if (!name) return null
+ const statusLabel =
+ status === 'playing' ? 'En lecture' : status === 'paused' ? 'En pause' : 'Arrêté'
+ return (
+
+
+
+ {status === 'playing' ? : }
+
+
+
+
+
+ )
+}
+
+function PlaybackIdentity({ name, status }: { name: string; status: PlaybackStatus }) {
+ const { t } = useStudioLanguage()
+ const statusLabel =
+ status === 'playing' ? 'En lecture' : status === 'paused' ? 'En pause' : 'Arrêté'
+ return (
+
+
+
+ {t(statusLabel)}
+ {name}
+
+
+ )
+}
+
+function PanelTitle({
+ title,
+ subtitle,
+ level = 2,
+}: {
+ title: string
+ subtitle: string
+ level?: 2 | 3
+}) {
+ const { t } = useStudioLanguage()
+ const Heading = level === 3 ? 'h3' : 'h2'
+ return (
+
+ {t(title)}
+ {t(subtitle)}
+
+ )
+}
+
+export default function App() {
+ return (
+
+
+
+ )
+}
diff --git a/src/__tests__/ambient-motion-test.ts b/src/__tests__/ambient-motion-test.ts
new file mode 100644
index 0000000..8ebdcdc
--- /dev/null
+++ b/src/__tests__/ambient-motion-test.ts
@@ -0,0 +1,46 @@
+import { ambientBodyOffset, applyAmbientMotion, hasAmbientMotion } from '../ambientMotion'
+import { defaultExpression } from '../presets'
+
+describe('perpetual expression motion', () => {
+ it('leaves a motionless expression unchanged', () => {
+ const expression = { ...defaultExpression }
+
+ expect(hasAmbientMotion(expression)).toBe(false)
+ expect(applyAmbientMotion(expression, 500)).toEqual(expression)
+ })
+
+ it('moves both eyes together without mutating the saved expression', () => {
+ const expression = { ...defaultExpression, eyeMotion: 'microSaccades' as const }
+ const animated = applyAmbientMotion(expression, 500)
+
+ expect(animated.positionXLeft - expression.positionXLeft).toBeCloseTo(
+ animated.positionXRight - expression.positionXRight
+ )
+ expect(animated.positionYLeft - expression.positionYLeft).toBeCloseTo(
+ animated.positionYRight - expression.positionYRight
+ )
+ expect(animated.positionXLeft).not.toBe(expression.positionXLeft)
+ expect(expression.positionXLeft).toBe(defaultExpression.positionXLeft)
+ })
+
+ it('adds body motion without changing eye placement', () => {
+ const expression = { ...defaultExpression, bodyMotion: 'shake' as const }
+ const animated = applyAmbientMotion(expression, 500)
+
+ expect(animated.headY).not.toBe(expression.headY)
+ expect(animated.positionXLeft).toBe(expression.positionXLeft)
+ expect(animated.positionYRight).toBe(expression.positionYRight)
+ })
+
+ it('visibly offsets a symmetric body when it shakes', () => {
+ const expression = { ...defaultExpression, bodyMotion: 'shake' as const }
+
+ expect(ambientBodyOffset(expression, 500)).not.toEqual({ x: 0, y: 0 })
+ })
+
+ it('visibly offsets a symmetric body during slow drift', () => {
+ const expression = { ...defaultExpression, bodyMotion: 'slowDrift' as const }
+
+ expect(ambientBodyOffset(expression, 1500)).not.toEqual({ x: 0, y: 0 })
+ })
+})
diff --git a/src/__tests__/avatars-test.ts b/src/__tests__/avatars-test.ts
new file mode 100644
index 0000000..612bd9a
--- /dev/null
+++ b/src/__tests__/avatars-test.ts
@@ -0,0 +1,59 @@
+import { defaultExpression } from '../presets'
+import { createInitialSequences } from '../sequences'
+import {
+ applyAvatarEyeDefaults,
+ cloneAvatarBehavior,
+ createAvatar,
+ defaultAvatarEyes,
+ parseAvatarEyeDefaults,
+ resolveAvatarBehavior,
+} from '../avatars'
+import { initialExpressions } from '../presets'
+
+describe('avatar eye defaults', () => {
+ it('keeps the historical rendering when using default values', () => {
+ expect(applyAvatarEyeDefaults(defaultExpression, defaultAvatarEyes)).toEqual(defaultExpression)
+ })
+
+ it('composes avatar defaults as variations around the neutral expression', () => {
+ const expression = { ...defaultExpression, widthLeft: 28, positionYLeft: 5 }
+ const eyes = { ...defaultAvatarEyes, widthLeft: 30, positionYLeft: -12 }
+
+ const result = applyAvatarEyeDefaults(expression, eyes)
+
+ expect(result.widthLeft).toBe(38)
+ expect(result.positionYLeft).toBe(0)
+ expect(expression.widthLeft).toBe(28)
+ })
+
+ it('sanitizes partial persisted values', () => {
+ const result = parseAvatarEyeDefaults({ widthLeft: 42, heightRight: Number.NaN })
+
+ expect(result.widthLeft).toBe(42)
+ expect(result.heightRight).toBe(defaultAvatarEyes.heightRight)
+ expect(result.spacing).toBe(defaultAvatarEyes.spacing)
+ })
+})
+
+describe('avatar behavior library', () => {
+ const base = {
+ expressions: initialExpressions,
+ sequences: createInitialSequences(),
+ }
+
+ it('inherits the base library until the avatar owns a customization', () => {
+ const avatar = createAvatar('Strobi')
+
+ expect(resolveAvatarBehavior(avatar, base)).toBe(base)
+ })
+
+ it('clones expressions, animations and nested steps as one independent library', () => {
+ const behavior = cloneAvatarBehavior(base)
+
+ expect(behavior).not.toBe(base)
+ expect(behavior.expressions).not.toBe(base.expressions)
+ expect(behavior.sequences).not.toBe(base.sequences)
+ expect(behavior.sequences[0].steps).not.toBe(base.sequences[0].steps)
+ expect(behavior.sequences[0].blink).not.toBe(base.sequences[0].blink)
+ })
+})
diff --git a/src/__tests__/canvas-preview-test.ts b/src/__tests__/canvas-preview-test.ts
new file mode 100644
index 0000000..8b37226
--- /dev/null
+++ b/src/__tests__/canvas-preview-test.ts
@@ -0,0 +1,37 @@
+import { applyAvatarEyeDefaults, defaultAvatarEyes } from '../avatars'
+import { resolveCanvasPreviewExpression } from '../canvasPreview'
+import { defaultExpression } from '../presets'
+
+describe('canvas preview expression', () => {
+ it('keeps customized neutral eyes applied while rotating the body preview', () => {
+ const eyes = {
+ ...defaultAvatarEyes,
+ widthLeft: 37,
+ positionYLeft: -18,
+ leftAngle: 21,
+ rightAngle: -21,
+ }
+ const draggedExpression = { ...defaultExpression, headX: 24, headY: 31 }
+
+ expect(resolveCanvasPreviewExpression(draggedExpression, eyes, true, 'head')).toEqual(
+ applyAvatarEyeDefaults(draggedExpression, eyes)
+ )
+ })
+
+ it('does not compose neutral eyes twice outside body editing', () => {
+ const eyes = { ...defaultAvatarEyes, widthLeft: 37 }
+
+ expect(resolveCanvasPreviewExpression(defaultExpression, eyes, false, 'head')).toBe(
+ defaultExpression
+ )
+ })
+
+ it('does not compose customized eyes twice while editing an eye handle', () => {
+ const eyes = { ...defaultAvatarEyes, widthLeft: 37 }
+ const customizedExpression = applyAvatarEyeDefaults(defaultExpression, eyes)
+
+ expect(resolveCanvasPreviewExpression(customizedExpression, eyes, true, 'eyes')).toBe(
+ customizedExpression
+ )
+ })
+})
diff --git a/src/__tests__/exporter-test.ts b/src/__tests__/exporter-test.ts
new file mode 100644
index 0000000..d6cf933
--- /dev/null
+++ b/src/__tests__/exporter-test.ts
@@ -0,0 +1,154 @@
+import { createAvatar } from '../avatars'
+import { parse } from '@babel/parser'
+import {
+ createAvatarExportPayload,
+ generateJavaScriptAvatarHtml,
+ generateJavaScriptAvatarModule,
+ generateJavaScriptAvatarPackage,
+ generateReactAvatarComponent,
+ generateReactAvatarPackage,
+ generateReactAvatarRuntime,
+} from '../exporter'
+import { initialExpressions } from '../presets'
+import { createInitialSequences } from '../sequences'
+
+describe('avatar export', () => {
+ const avatar = createAvatar('Strobi')
+ const animations = createInitialSequences().filter(item =>
+ ['idle', 'listening'].includes(item.id)
+ )
+ const payload = createAvatarExportPayload(avatar, initialExpressions, animations)
+
+ it('includes only the selected animations and their referenced expressions', () => {
+ expect(Object.keys(payload.animations)).toEqual(['idle', 'listening'])
+ expect(Object.keys(payload.expressions).sort()).toEqual(
+ [
+ ...new Set(animations.flatMap(animation => animation.steps.map(step => step.expressionId))),
+ ].sort()
+ )
+ expect(payload).not.toHaveProperty('frames')
+ expect(payload.avatar.name).toBe('Strobi')
+ })
+
+ it('generates a standalone JavaScript module without a Web Component', () => {
+ const source = generateJavaScriptAvatarModule(payload)
+
+ expect(source).toContain('export function createAvatar')
+ expect(source).toContain('play(animationName)')
+ expect(source).toContain('export const availableAnimations')
+ expect(source).toContain('pausedRemainingMs')
+ expect(source).toContain('pausedBlinkDelay')
+ expect(source).toContain('pausedBlink')
+ expect(source).toContain('step.transition')
+ expect(source).toContain('stepIndex = 0')
+ expect(source).not.toContain('customElements.define')
+ expect(source).not.toContain("from '")
+ expect(() => parse(source, { sourceType: 'module' })).not.toThrow()
+ })
+
+ it('uses the Studio geometry engine to render interpolated transition frames', () => {
+ const source = generateJavaScriptAvatarModule(payload)
+
+ expect(source).toContain('AvatarProceduralEngine')
+ expect(source).toContain('expressionFields')
+ expect(source).toContain('requestAnimationFrame')
+ expect(source).not.toContain('avatarFrameIn')
+ })
+
+ it('generates an HTML demo that works when opened directly from the filesystem', () => {
+ const source = generateJavaScriptAvatarHtml(payload)
+
+ expect(source).not.toContain("import { createAvatar } from './avatar.js'")
+ expect(source).toContain('function createAvatar')
+ expect(source).toContain('avatar.play("idle")')
+ })
+
+ it('generates an interactive playground for the exported animations', () => {
+ const source = generateJavaScriptAvatarHtml(payload)
+
+ expect(source).toContain('id="animation-list"')
+ expect(source).toContain('id="play-animation"')
+ expect(source).toContain('id="pause-animation"')
+ expect(source).toContain('id="stop-animation"')
+ expect(source).toContain('availableAnimations.forEach')
+ expect(source).toContain('avatar.play(activeAnimation)')
+ expect(source).toContain('avatar.pause()')
+ expect(source).toContain('avatar.stop()')
+ expect(source).toContain("createAvatar('#avatar'")
+ })
+
+ it('localizes the exported playground', () => {
+ const source = generateJavaScriptAvatarHtml(payload, 'fr')
+
+ expect(source).toContain('')
+ expect(source).toContain('Choisissez une animation exportée')
+ expect(source).toContain('▶ Lire')
+ expect(source).toContain('En pause')
+ })
+
+ it('keeps user-authored names and descriptions inside the generated script', () => {
+ const source = generateJavaScriptAvatarHtml({
+ ...payload,
+ avatar: { ...payload.avatar, name: '' },
+ })
+
+ expect(source).not.toContain('')
+ expect(source).toContain('</title>')
+ expect(source).toContain('\\u003c/script>')
+ })
+
+ it('packages the direct-open demo and reusable module together', async () => {
+ const archive = new Uint8Array(await generateJavaScriptAvatarPackage(payload).arrayBuffer())
+ const contents = new TextDecoder().decode(archive)
+
+ expect(contents).toContain('index.html')
+ expect(contents).toContain('avatar.js')
+ })
+
+ it('generates a typed React component backed by the local runtime', () => {
+ const source = generateReactAvatarComponent(payload)
+
+ expect(source).toContain("from 'react'")
+ expect(source).toContain("from './avatar-runtime'")
+ expect(source).toContain("from './strobi.avatar'")
+ expect(source).toContain('export type { AnimationName }')
+ expect(source).toContain('animation?: AnimationName')
+ expect(source).toContain('forwardRef')
+ expect(source).not.toContain('AvatarProceduralEngine')
+ expect(source).toContain('runtime.createAvatar(host.current')
+ expect(source).not.toContain('@ts-nocheck')
+ expect(source).not.toContain('avatarFrameIn')
+ expect(source).not.toContain("from 'motion")
+ expect(() =>
+ parse(source, { sourceType: 'module', plugins: ['typescript', 'jsx'] })
+ ).not.toThrow()
+ })
+
+ it('packages the React runtime, avatar data and component separately', async () => {
+ const archive = new Uint8Array(await generateReactAvatarPackage(payload).arrayBuffer())
+ const contents = new TextDecoder().decode(archive)
+
+ expect(contents).toContain('avatar-runtime.ts')
+ expect(contents).toContain('strobi.avatar.ts')
+ expect(contents).toContain('Strobi.tsx')
+ expect(contents).toContain('strobi.index.ts')
+ expect(contents).toContain("from './avatar-runtime'")
+ })
+
+ it('generates one avatar-independent and versioned React runtime', () => {
+ const runtime = generateReactAvatarRuntime()
+
+ expect(runtime).toContain('AVATAR_RUNTIME_VERSION = 1')
+ expect(runtime).toContain('data.version !== AVATAR_RUNTIME_VERSION')
+ expect(runtime).not.toContain(payload.avatar.name)
+ expect(runtime).toContain('RuntimeAvatar')
+ expect(() => parse(runtime, { sourceType: 'module', plugins: ['typescript'] })).not.toThrow()
+ })
+
+ it('uses globally unique SVG identifiers across avatar runtimes', () => {
+ const source = generateJavaScriptAvatarModule(payload)
+
+ expect(source).toContain('globalThis.crypto.randomUUID()')
+ expect(source).not.toContain('avatarInstanceCount')
+ })
+})
diff --git a/src/__tests__/expression-editing-test.ts b/src/__tests__/expression-editing-test.ts
new file mode 100644
index 0000000..88c3567
--- /dev/null
+++ b/src/__tests__/expression-editing-test.ts
@@ -0,0 +1,24 @@
+import { updateEyeDimension, scaleEye, updateEyePosition } from '../expressionEditing'
+import { defaultExpression } from '../presets'
+
+describe('Expression eye editing', () => {
+ it('applies linked dimensions through one invariant-preserving operation', () => {
+ const next = updateEyeDimension(defaultExpression, 'Left', 'width', 140, true)
+ expect(next.widthLeft).toBe(100)
+ expect(next.widthRight).toBe(100)
+ })
+
+ it('scales both eyes proportionally without exceeding eye bounds', () => {
+ const next = scaleEye(defaultExpression, 'Left', 100, true)
+ expect(next.heightLeft).toBe(100)
+ expect(next.widthLeft).toBe(40)
+ expect(next.heightRight).toBe(100)
+ expect(next.widthRight).toBe(40)
+ })
+
+ it('keeps linked eye positions symmetrical as an editing rule', () => {
+ const next = updateEyePosition(defaultExpression, 'Right', 'Y', 12, true)
+ expect(next.positionYLeft).toBe(12)
+ expect(next.positionYRight).toBe(12)
+ })
+})
diff --git a/src/__tests__/geometry-editor-test.ts b/src/__tests__/geometry-editor-test.ts
new file mode 100644
index 0000000..dbe65e9
--- /dev/null
+++ b/src/__tests__/geometry-editor-test.ts
@@ -0,0 +1,91 @@
+import type { BodyNode } from '../body'
+import {
+ poseFromExpression,
+ renderAvatar,
+ renderBodyNodeEditor,
+ rotateBodyNodeAroundLocalAxis,
+ translateBodyNodeInCameraPlane,
+ translateBodyNodeAlongLocalAxis,
+} from '../geometry'
+import { defaultExpression } from '../presets'
+import { surfacePresets } from '../surfaces'
+
+const node: BodyNode = {
+ id: 'shape-test',
+ name: 'Forme test',
+ surface: surfacePresets.cube,
+ position: [0, 0, -20],
+ rotation: [0, 0, 0],
+}
+
+describe('body node editor geometry', () => {
+ it('keeps all translation axes manipulable when one points at the camera', () => {
+ const editor = renderBodyNodeEditor(poseFromExpression(defaultExpression), node)
+
+ expect(
+ Math.hypot(editor.axes.z[0] - editor.center[0], editor.axes.z[1] - editor.center[1])
+ ).toBeGreaterThanOrEqual(12)
+ expect(Object.values(editor.axes).flat().every(Number.isFinite)).toBe(true)
+ })
+
+ it('projects complete local rotation rings around the selected node', () => {
+ const editor = renderBodyNodeEditor(poseFromExpression(defaultExpression), {
+ ...node,
+ rotation: [20, -15, 35],
+ })
+
+ expect(editor.rings.x).toHaveLength(65)
+ expect(editor.rings.y).toHaveLength(65)
+ expect(editor.rings.z).toHaveLength(65)
+ expect(editor.rings.z[0]).not.toEqual(editor.rings.z[16])
+ })
+
+ it('moves along the rotated local axis', () => {
+ const translated = translateBodyNodeAlongLocalAxis({ ...node, rotation: [0, 0, 90] }, 'x', 20)
+
+ expect(translated.position[0]).toBeCloseTo(0)
+ expect(translated.position[1]).toBeCloseTo(20)
+ expect(translated.position[2]).toBeCloseTo(-20)
+ })
+
+ it('moves freely in the camera plane regardless of head rotation', () => {
+ const pose = poseFromExpression({ ...defaultExpression, headX: 24, headY: -31, headZ: 18 })
+ const before = renderBodyNodeEditor(pose, node).center
+ const translated = translateBodyNodeInCameraPlane(node, pose, 18, -9)
+ const after = renderBodyNodeEditor(pose, translated).center
+
+ expect(after[0] - before[0]).toBeCloseTo(18)
+ expect(after[1] - before[1]).toBeCloseTo(-9)
+ expect(translated.position[2]).not.toBe(node.position[2])
+ })
+
+ it('composes a local rotation instead of incrementing a raw Euler field', () => {
+ const rotated = rotateBodyNodeAroundLocalAxis({ ...node, rotation: [25, 30, -20] }, 'x', 15)
+
+ expect(rotated.rotation.every(Number.isFinite)).toBe(true)
+ expect(rotated.rotation).not.toEqual([40, 30, -20])
+ })
+
+ it('keeps equivalent Euler values near their current free rotation', () => {
+ const rotated = rotateBodyNodeAroundLocalAxis({ ...node, rotation: [0, 0, 350] }, 'x', 1)
+
+ expect(rotated.rotation[2]).toBeGreaterThan(300)
+ })
+
+ it('keeps a spherical silhouette path stable across head rotations', () => {
+ const neutralPath = renderAvatar(
+ poseFromExpression(defaultExpression),
+ surfacePresets.sphere,
+ 1,
+ { includeWire: false }
+ ).headPath
+ const rotatedPath = renderAvatar(
+ poseFromExpression({ ...defaultExpression, headX: 17, headY: 34, headZ: -21 }),
+ surfacePresets.sphere,
+ 1,
+ { includeWire: false }
+ ).headPath
+
+ expect(rotatedPath).toBe(neutralPath)
+ })
+})
diff --git a/src/__tests__/i18n-test.ts b/src/__tests__/i18n-test.ts
new file mode 100644
index 0000000..f68e0cf
--- /dev/null
+++ b/src/__tests__/i18n-test.ts
@@ -0,0 +1,45 @@
+import { translateStudioText } from '../i18n'
+
+describe('avatar studio translations', () => {
+ it('uses English for static interface copy', () => {
+ expect(translateStudioText('Couleur des yeux', 'en')).toBe('Eye color')
+ })
+
+ it('translates dynamic editor labels', () => {
+ expect(translateStudioText('Modifier l’expression 08', 'en')).toBe('Edit expression 08')
+ expect(translateStudioText('Sphère · porte les yeux', 'en')).toBe('Sphere · carries the eyes')
+ expect(translateStudioText('Cône 1 copie', 'en')).toBe('Cone 1 copy')
+ })
+
+ it('keeps the original French copy in French', () => {
+ expect(translateStudioText('Nouvel avatar', 'fr')).toBe('Nouvel avatar')
+ expect(translateStudioText('sleeping', 'fr')).toBe('sommeil')
+ })
+
+ it('translates the studio interface and dynamic labels to Simplified Chinese', () => {
+ expect(translateStudioText('Couleur des yeux', 'zh-CN')).toBe('眼睛颜色')
+ expect(translateStudioText('Modifier l’expression 08', 'zh-CN')).toBe('编辑表情 08')
+ expect(translateStudioText('sleeping', 'zh-CN')).toBe('睡眠')
+ })
+
+ it('translates every Photo Mode control to Simplified Chinese', () => {
+ expect(translateStudioText('Mode photo', 'zh-CN')).toBe('照片模式')
+ expect(translateStudioText('Dégradé radial', 'zh-CN')).toBe('径向渐变')
+ expect(translateStudioText('Définition du mode photo', 'zh-CN')).toBe('照片模式分辨率')
+ expect(translateStudioText('Télécharger en PNG', 'zh-CN')).toBe('下载 PNG')
+ expect(translateStudioText('Prendre une photo', 'zh-CN')).toBe('拍照')
+ expect(translateStudioText('Format d’export', 'zh-CN')).toBe('导出格式')
+ })
+
+ it('covers every configured state description in English', () => {
+ expect(translateStudioText('Rythme régulier et expressions concentrées.', 'en')).toBe(
+ 'Steady rhythm and focused expressions.'
+ )
+ expect(translateStudioText('Grandes expressions et transitions rapides.', 'en')).toBe(
+ 'Big expressions and fast transitions.'
+ )
+ expect(translateStudioText('Inclinaisons et forte asymétrie.', 'en')).toBe(
+ 'Tilts and strong asymmetry.'
+ )
+ })
+})
diff --git a/src/__tests__/manipulation-session-test.ts b/src/__tests__/manipulation-session-test.ts
new file mode 100644
index 0000000..7b8d4d4
--- /dev/null
+++ b/src/__tests__/manipulation-session-test.ts
@@ -0,0 +1,47 @@
+import {
+ beginManipulation,
+ beginManipulationFromRenderedValue,
+ finishManipulation,
+ previewManipulation,
+} from '../manipulationSession'
+
+describe('pointer manipulation session', () => {
+ it('previews many values and commits exactly once', () => {
+ const previews: number[] = []
+ const commits: number[] = []
+ const session = beginManipulation(10)
+ previewManipulation(session, 11, value => previews.push(value))
+ previewManipulation(session, 12, value => previews.push(value))
+
+ finishManipulation(session, 'commit', {
+ preview: value => previews.push(value),
+ commit: value => commits.push(value),
+ })
+
+ expect(previews).toEqual([11, 12])
+ expect(commits).toEqual([12])
+ })
+
+ it('restores the initial value when pointer capture is cancelled', () => {
+ const previews: number[] = []
+ const session = beginManipulation(10)
+ previewManipulation(session, 20, () => undefined)
+
+ finishManipulation(session, 'cancel', {
+ preview: value => previews.push(value),
+ commit: () => undefined,
+ })
+
+ expect(previews).toEqual([10])
+ })
+
+ it('starts from the live rendered value while an interpolation is running', () => {
+ const staleInspectorValue = { headX: 0, headY: 0 }
+ const renderedMidTransition = { headX: 18, headY: 27 }
+
+ const session = beginManipulationFromRenderedValue(() => renderedMidTransition)
+
+ expect(session.initial).toBe(renderedMidTransition)
+ expect(session.initial).not.toBe(staleInspectorValue)
+ })
+})
diff --git a/src/__tests__/playback-test.ts b/src/__tests__/playback-test.ts
new file mode 100644
index 0000000..a6fe56a
--- /dev/null
+++ b/src/__tests__/playback-test.ts
@@ -0,0 +1,43 @@
+import { createInitialSequences } from '../sequences'
+import {
+ advancePlaybackTimeline,
+ beginPlayback,
+ createPlaybackTimeline,
+ pausePlaybackTimeline,
+ schedulePlaybackBlink,
+ schedulePlaybackStep,
+} from '../playback'
+
+describe('State playback timeline', () => {
+ it('preserves the current sequence position when the avatar changes', () => {
+ const timeline = createPlaybackTimeline()
+ timeline.position = 1
+ timeline.direction = -1
+
+ const preserved = beginPlayback(timeline, true)
+
+ expect(preserved.position).toBe(1)
+ expect(preserved.direction).toBe(-1)
+ })
+
+ it('pauses and resumes from the exact remaining delays', () => {
+ const timeline = createPlaybackTimeline()
+ const scheduledStep = schedulePlaybackStep(timeline, 1_000, 500)
+ const scheduled = schedulePlaybackBlink(scheduledStep, 1_000, 900)
+
+ const paused = pausePlaybackTimeline(scheduled, 1_200)
+
+ expect(paused.stepRemainingMs).toBe(300)
+ expect(paused.blinkRemainingMs).toBe(700)
+ })
+
+ it('owns cursor advancement for the active State', () => {
+ const idle = createInitialSequences().find(sequence => sequence.id === 'idle')!
+ const timeline = createPlaybackTimeline()
+
+ const result = advancePlaybackTimeline(timeline, idle)
+
+ expect(result.cursor.complete).toBe(false)
+ expect(result.timeline.position).toBe(1)
+ })
+})
diff --git a/src/__tests__/presets-test.ts b/src/__tests__/presets-test.ts
new file mode 100644
index 0000000..563884e
--- /dev/null
+++ b/src/__tests__/presets-test.ts
@@ -0,0 +1,26 @@
+import { getStatePlaybackConfig } from '../presets'
+
+describe('state playback configuration', () => {
+ it('keeps idle slower than an active sequence', () => {
+ expect(getStatePlaybackConfig('idle').expressionIntervalMs).toBe(5200)
+ expect(getStatePlaybackConfig('listening').expressionIntervalMs).toBe(2300)
+ })
+
+ it('describes a valid randomized blink interval', () => {
+ const { blink } = getStatePlaybackConfig('idle')
+
+ expect(blink.initialDelayMs).toBeGreaterThan(0)
+ expect(blink.minIntervalMs).toBeLessThan(blink.maxIntervalMs)
+ expect(blink.durationMs).toBe(280)
+ })
+
+ it('uses a blink rhythm adapted to each sequence family', () => {
+ const sleeping = getStatePlaybackConfig('sleeping').blink
+ const listening = getStatePlaybackConfig('listening').blink
+ const excited = getStatePlaybackConfig('excited').blink
+
+ expect(sleeping.minIntervalMs).toBeGreaterThan(listening.minIntervalMs)
+ expect(listening.minIntervalMs).toBeGreaterThan(excited.minIntervalMs)
+ expect(sleeping.durationMs).toBeGreaterThan(excited.durationMs)
+ })
+})
diff --git a/src/__tests__/rendered-rotation-gizmo-test.ts b/src/__tests__/rendered-rotation-gizmo-test.ts
new file mode 100644
index 0000000..5ca3274
--- /dev/null
+++ b/src/__tests__/rendered-rotation-gizmo-test.ts
@@ -0,0 +1,20 @@
+import { defaultExpression } from '../presets'
+import { createRenderedRotationGizmo, paintRenderedRotationGizmo } from '../renderedRotationGizmo'
+
+describe('rendered rotation gizmo', () => {
+ it('updates its rings without requiring a React render', () => {
+ const gizmo = createRenderedRotationGizmo(defaultExpression)
+ const initialXPath = gizmo.xPath.get()
+ const initialYPath = gizmo.yPath.get()
+
+ paintRenderedRotationGizmo(gizmo, {
+ ...defaultExpression,
+ headX: 28,
+ headY: -36,
+ headZ: 17,
+ })
+
+ expect(gizmo.xPath.get()).not.toBe(initialXPath)
+ expect(gizmo.yPath.get()).not.toBe(initialYPath)
+ })
+})
diff --git a/src/__tests__/rendered-scene-test.ts b/src/__tests__/rendered-scene-test.ts
new file mode 100644
index 0000000..8bc3525
--- /dev/null
+++ b/src/__tests__/rendered-scene-test.ts
@@ -0,0 +1,72 @@
+import { createBodyNode } from '../body'
+import { renderAvatar, poseFromExpression } from '../geometry'
+import { defaultExpression } from '../presets'
+import {
+ createRenderedColors,
+ createRenderedScene,
+ findBodyNodePath,
+ paintRenderedColors,
+ paintRenderedScene,
+} from '../renderedScene'
+import { surfacePresets } from '../surfaces'
+import defaultStudioDocument from '../defaultStudioDocument.json'
+import type { BodyNode } from '../body'
+import type { Expression } from '../geometry'
+import type { SurfaceConfig } from '../surfaces'
+
+describe('rendered avatar scene', () => {
+ it('keeps layer identity and hit mapping behind the scene seam', () => {
+ const node = createBodyNode('sphere', 0)
+ const first = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1, {
+ bodyNodes: [node],
+ })
+ const scene = createRenderedScene(first)
+ const rotated = renderAvatar(
+ poseFromExpression({ ...defaultExpression, headY: 35 }),
+ surfacePresets.sphere,
+ 1,
+ { bodyNodes: [node] }
+ )
+
+ paintRenderedScene(scene, rotated)
+
+ expect(findBodyNodePath(scene, 'primary')).toBe(scene.headPath)
+ expect(findBodyNodePath(scene, node.id)).not.toBeNull()
+ expect(scene.headPath.get()).toBe(rotated.headPath)
+ })
+
+ it('updates animated colors without replacing their motion values', () => {
+ const colors = createRenderedColors({ body: '#5b7fe5', eyes: '#111316' })
+ const body = colors.body
+ const eyes = colors.eyes
+
+ paintRenderedColors(colors, { body: '#c53b47', eyes: '#ffffff' })
+
+ expect(colors.body).toBe(body)
+ expect(colors.eyes).toBe(eyes)
+ expect(colors.body.get()).toBe('#c53b47')
+ expect(colors.eyes.get()).toBe('#ffffff')
+ })
+
+ it('keeps Cloudee accessories behind the eyes at expression position 05', () => {
+ const avatar = defaultStudioDocument.library.avatars.find(item => item.name === 'Cloudee')!
+ const expression = defaultStudioDocument.expressions[5] as Expression
+
+ const geometry = renderAvatar(
+ poseFromExpression(expression),
+ avatar.body.primary as SurfaceConfig,
+ 1,
+ { bodyNodes: avatar.body.nodes as BodyNode[] }
+ )
+
+ expect(geometry.frontNodeIds).toEqual([])
+
+ const clearlyTurned = renderAvatar(
+ poseFromExpression({ ...expression, headY: -35 }),
+ avatar.body.primary as SurfaceConfig,
+ 1,
+ { bodyNodes: avatar.body.nodes as BodyNode[] }
+ )
+ expect(clearlyTurned.frontNodeIds).toContain('shape-d4b4e8ad-8625-488d-920c-c497da226f9f')
+ })
+})
diff --git a/src/__tests__/sequences-test.ts b/src/__tests__/sequences-test.ts
new file mode 100644
index 0000000..6c5b694
--- /dev/null
+++ b/src/__tests__/sequences-test.ts
@@ -0,0 +1,97 @@
+import {
+ advanceSequenceCursor,
+ createInitialSequences,
+ getSequenceSpring,
+ normalizeSequencesForExpressions,
+ parseSequences,
+ remapSequencesAfterExpressionDelete,
+} from '../sequences'
+import { initialExpressions } from '../presets'
+
+describe('editable avatar sequences', () => {
+ it('creates the idle animation with editable steps and blink settings', () => {
+ const idle = createInitialSequences().find(sequence => sequence.id === 'idle')
+
+ expect(idle?.steps.map(step => step.expressionId)).toEqual([
+ initialExpressions[0].id,
+ initialExpressions[8].id,
+ ])
+ expect(idle?.steps[0].holdMs).toBe(5200)
+ expect(idle?.blink.durationMs).toBe(280)
+ })
+
+ it('supports loop, once and ping-pong playback cursors', () => {
+ const base = createInitialSequences().find(sequence => sequence.id === 'listening')!
+
+ expect(advanceSequenceCursor({ ...base, playbackMode: 'loop' }, 2, 1)).toEqual({
+ index: 0,
+ direction: 1,
+ complete: false,
+ })
+ expect(advanceSequenceCursor({ ...base, playbackMode: 'once' }, 2, 1).complete).toBe(true)
+ expect(advanceSequenceCursor({ ...base, playbackMode: 'pingPong' }, 2, 1)).toEqual({
+ index: 1,
+ direction: -1,
+ complete: false,
+ })
+ })
+
+ it('keeps sequence references stable when expressions are reordered', () => {
+ const sequence = createInitialSequences().find(item => item.id === 'idle')!
+ const reordered = [...initialExpressions].reverse()
+ const [normalized] = normalizeSequencesForExpressions([sequence], reordered)
+
+ expect(normalized.steps.map(step => step.expressionId)).toEqual(
+ sequence.steps.map(step => step.expressionId)
+ )
+ })
+
+ it('keeps a sequence playable when its only referenced expression is deleted', () => {
+ const sequence = createInitialSequences().find(item => item.id === 'waking')!
+ const fallbackId = initialExpressions[12].id
+ const [remapped] = remapSequencesAfterExpressionDelete(
+ [sequence],
+ initialExpressions[13].id,
+ fallbackId
+ )
+
+ expect(remapped.steps).toHaveLength(1)
+ expect(remapped.steps[0].expressionId).toBe(fallbackId)
+ })
+
+ it('sanitizes persisted timing values and invalid playback values', () => {
+ const [sequence] = parseSequences([
+ {
+ id: 'custom',
+ name: 'Custom',
+ playbackMode: 'invalid',
+ steps: [{ expressionId: initialExpressions[2].id, holdMs: -5, transitionMs: 99999 }],
+ blink: { minIntervalMs: 9000, maxIntervalMs: 1000, durationMs: 2 },
+ },
+ ])
+
+ expect(sequence.playbackMode).toBe('loop')
+ expect(sequence.steps[0].holdMs).toBe(100)
+ expect(sequence.steps[0].transitionMs).toBe(5000)
+ expect(sequence.blink.maxIntervalMs).toBe(sequence.blink.minIntervalMs)
+ expect(sequence.blink.durationMs).toBe(40)
+ })
+
+ it('repairs missing and out-of-range expression references on load', () => {
+ const sequence = createInitialSequences().find(item => item.id === 'idle')!
+ const [normalized] = normalizeSequencesForExpressions(
+ [{ ...sequence, steps: [{ ...sequence.steps[0], expressionId: 'missing' }] }],
+ initialExpressions.slice(0, 4)
+ )
+
+ expect(normalized.steps[0].expressionId).toBe(initialExpressions[0].id)
+ })
+
+ it('maps transition styles and durations to distinct spring dynamics', () => {
+ const smooth = getSequenceSpring('smooth', 900, 7)
+ const snappy = getSequenceSpring('snappy', 250, 7)
+
+ expect(snappy.stiffness).toBeGreaterThan(smooth.stiffness)
+ expect(smooth.damping).toBeGreaterThan(0)
+ })
+})
diff --git a/src/__tests__/snapshot-exporter-test.ts b/src/__tests__/snapshot-exporter-test.ts
new file mode 100644
index 0000000..ba2fb8c
--- /dev/null
+++ b/src/__tests__/snapshot-exporter-test.ts
@@ -0,0 +1,41 @@
+import { poseFromExpression, renderAvatar } from '../geometry'
+import { defaultExpression } from '../presets'
+import { createRenderedScene, paintRenderedOffset } from '../renderedScene'
+import { serializeAvatarSnapshot, snapshotFileName } from '../snapshotExporter'
+import { surfacePresets } from '../surfaces'
+
+describe('avatar snapshot export', () => {
+ const geometry = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1)
+ const scene = createRenderedScene(geometry)
+ const colors = { body: '#5b7fe5', eyes: '#111316' }
+
+ it('exports the currently rendered scene as a transparent SVG', () => {
+ paintRenderedOffset(scene, { x: 3, y: -2 })
+ const svg = serializeAvatarSnapshot('Strobi', scene, colors, {
+ background: 'transparent',
+ colorFrom: '#ffffff',
+ colorTo: '#000000',
+ size: 1024,
+ })
+
+ expect(svg).toContain('width="1024" height="1024"')
+ expect(svg).toContain('transform="translate(3 -2)"')
+ expect(svg).toContain(`d="${geometry.headPath}" fill="#5b7fe5"`)
+ expect(svg).toContain('fill="#111316"')
+ expect(svg).not.toContain(' {
+ const svg = serializeAvatarSnapshot('Strobi', scene, colors, {
+ background: 'radial',
+ colorFrom: '#ffffff',
+ colorTo: '#8899aa',
+ size: 512,
+ })
+
+ expect(svg).toContain(' {
+ const avatar = createAvatar('Strobi')
+ return {
+ version: 2,
+ library: {
+ activeAvatarId: avatar.id,
+ avatars: [avatar],
+ },
+ expressions: initialExpressions,
+ sequences: createInitialSequences(),
+ playback: { stateId: 'idle', playing: true },
+ }
+}
+
+describe('Studio document', () => {
+ const storage = (value: string | null = null) => ({ getItem: () => value })
+
+ it('loads the bundled Studio snapshot when no local project exists', () => {
+ const document = loadStudioDocument(storage())
+
+ expect(document.library.avatars).toHaveLength(10)
+ expect(document.library.activeAvatarId).toBe(document.library.avatars[0].id)
+ expect(document.library.avatars[0].name).toBe('Strobi')
+ expect(document.expressions).toHaveLength(27)
+ expect(document.sequences).toHaveLength(23)
+ expect(document.playback).toEqual({ stateId: 'proud', playing: true })
+ })
+
+ it('keeps a locally saved project authoritative over the bundled snapshot', () => {
+ const localDocument = documentFixture()
+
+ expect(loadStudioDocument(storage(JSON.stringify(localDocument)))).toEqual(localDocument)
+ })
+
+ it('persists one coherent document after a mutation', () => {
+ const persisted: StudioDocument[] = []
+ const store = createStudioDocumentStore(documentFixture(), value => persisted.push(value))
+
+ store.update({ playback: { stateId: 'idle', playing: false } })
+
+ expect(persisted).toHaveLength(1)
+ expect(persisted[0].playback).toEqual({ stateId: 'idle', playing: false })
+ expect(persisted[0].expressions).toHaveLength(initialExpressions.length)
+ })
+
+ it('repairs sequence references in the same transaction as expression deletion', () => {
+ const store = createStudioDocumentStore(documentFixture(), () => undefined)
+ const remainingExpressions = initialExpressions.slice(1)
+
+ const next = store.update({ expressions: remainingExpressions })
+
+ expect(
+ next.sequences.every(sequence =>
+ sequence.steps.every(step =>
+ remainingExpressions.some(item => item.id === step.expressionId)
+ )
+ )
+ ).toBe(true)
+ })
+
+ it('round-trips a complete project document as portable JSON', () => {
+ const document = documentFixture()
+ const expression = { ...initialExpressions[0], widthLeft: 42 }
+ const sequence = {
+ ...createInitialSequences()[0],
+ steps: createInitialSequences()[0].steps.map(step => ({
+ ...step,
+ expressionId: expression.id,
+ })),
+ }
+ document.library.avatars[0].behavior = {
+ expressions: [expression],
+ sequences: [sequence],
+ }
+
+ const imported = parseImportedStudioDocument(serializeStudioDocument(document), document)
+
+ expect(imported).toEqual(document)
+ expect(imported.library.avatars[0].behavior?.expressions[0].widthLeft).toBe(42)
+ })
+
+ it('keeps the base library unchanged when an avatar owns customized behavior', () => {
+ const document = documentFixture()
+ const avatar = document.library.avatars[0]
+ const customized = {
+ ...avatar,
+ behavior: {
+ expressions: [{ ...initialExpressions[0], widthLeft: 47 }],
+ sequences: createInitialSequences().slice(0, 1),
+ },
+ }
+ const store = createStudioDocumentStore(document, () => undefined)
+
+ const next = store.update({
+ library: { activeAvatarId: avatar.id, avatars: [customized] },
+ })
+
+ expect(next.expressions[0].widthLeft).toBe(initialExpressions[0].widthLeft)
+ expect(next.library.avatars[0].behavior?.expressions[0].widthLeft).toBe(47)
+ })
+
+ it('rejects files that are not versioned Studio projects', () => {
+ const fallback = documentFixture()
+
+ expect(() => parseImportedStudioDocument('{"version":1}', fallback)).toThrow(
+ 'Unsupported Avatar Studio project'
+ )
+ expect(() => parseImportedStudioDocument('{broken', fallback)).toThrow(
+ 'Invalid Avatar Studio project'
+ )
+ })
+
+ it('repairs an imported active avatar and missing expression references', () => {
+ const fallback = documentFixture()
+ const avatar = createAvatar('Portable')
+ const imported = parseImportedStudioDocument(
+ JSON.stringify({
+ ...fallback,
+ library: { activeAvatarId: 'missing', avatars: [avatar] },
+ sequences: [
+ {
+ ...createInitialSequences()[0],
+ steps: [{ ...createInitialSequences()[0].steps[0], expressionId: 'missing' }],
+ },
+ ],
+ }),
+ fallback
+ )
+
+ expect(imported.library.activeAvatarId).toBe(avatar.id)
+ expect(imported.sequences[0].steps[0].expressionId).toBe(imported.expressions[0].id)
+ })
+
+ it('sanitizes imported animation timing and playback values', () => {
+ const fallback = documentFixture()
+ const imported = parseImportedStudioDocument(
+ JSON.stringify({
+ ...fallback,
+ sequences: [
+ {
+ ...fallback.sequences[0],
+ playbackMode: 'unsupported',
+ steps: [
+ {
+ ...fallback.sequences[0].steps[0],
+ holdMs: -500,
+ transitionMs: Number.POSITIVE_INFINITY,
+ },
+ ],
+ },
+ ],
+ }),
+ fallback
+ )
+
+ expect(imported.sequences[0].playbackMode).toBe('loop')
+ expect(imported.sequences[0].steps[0].holdMs).toBeGreaterThanOrEqual(100)
+ expect(Number.isFinite(imported.sequences[0].steps[0].transitionMs)).toBe(true)
+ })
+})
diff --git a/src/ambientMotion.ts b/src/ambientMotion.ts
new file mode 100644
index 0000000..2e392a0
--- /dev/null
+++ b/src/ambientMotion.ts
@@ -0,0 +1,96 @@
+import type { BodyMotion, Expression, EyeMotion } from './geometry'
+
+export const eyeMotionModes = ['none', 'microSaccades', 'shake'] as const
+export const bodyMotionModes = ['none', 'slowDrift', 'shake'] as const
+const eyeMotionSet = new Set(eyeMotionModes)
+const bodyMotionSet = new Set(bodyMotionModes)
+export const isEyeMotion = (value: unknown): value is EyeMotion =>
+ typeof value === 'string' && eyeMotionSet.has(value)
+export const isBodyMotion = (value: unknown): value is BodyMotion =>
+ typeof value === 'string' && bodyMotionSet.has(value)
+
+const smoothstep = (value: number) => value * value * (3 - 2 * value)
+const hash = (value: number) => {
+ const raw = Math.sin(value * 127.1 + 311.7) * 43758.5453
+ return (raw - Math.floor(raw)) * 2 - 1
+}
+
+const expressionSeed = (expression: Expression) =>
+ expression.headX * 0.71 + expression.headY * 1.13 + expression.headZ * 1.37
+
+const 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 warpedTime = elapsedMs + Math.sin(elapsedMs / 1700 + seed) * 280
+ const step = Math.floor(warpedTime / interval)
+ const progress = (((warpedTime % interval) + interval) % interval) / interval
+ const blend = smoothstep(Math.min(progress / 0.09, 1))
+ const previous = hash((step - 1) * 2 + axis + seed)
+ const next = hash(step * 2 + axis + seed)
+ const fadeIn = Math.min(elapsedMs / 240, 1)
+ return (previous + (next - previous) * blend) * fadeIn
+}
+
+export const hasAmbientMotion = (expression: Expression) =>
+ expression.eyeMotion !== 'none' || expression.bodyMotion !== 'none'
+
+export const ambientBodyOffset = (expression: Expression, elapsedMs: number) => {
+ const seed = expressionSeed(expression)
+ if (expression.bodyMotion === 'slowDrift') {
+ return {
+ x: smoothNoise(elapsedMs, 3, seed, 2900) * 1.45,
+ y: smoothNoise(elapsedMs, 4, seed, 3700) * 1.1,
+ }
+ }
+ if (expression.bodyMotion === 'shake') {
+ const time = elapsedMs / 1000
+ return {
+ x: (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.35,
+ y: (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.1,
+ }
+ }
+ return { x: 0, y: 0 }
+}
+
+export const applyAmbientMotion = (expression: Expression, elapsedMs: number): Expression => {
+ const next = { ...expression }
+ const seed = expressionSeed(expression)
+
+ if (expression.eyeMotion === 'microSaccades') {
+ const x = saccade(elapsedMs, 0, seed) * 1.5
+ const y = saccade(elapsedMs, 1, seed) * 0.9
+ next.positionXLeft += x
+ next.positionXRight += x
+ next.positionYLeft += y
+ next.positionYRight += y
+ } else if (expression.eyeMotion === 'shake') {
+ const time = elapsedMs / 1000
+ const x = (Math.sin(time * 47) + Math.sin(time * 71) * 0.45) * 1.2
+ const y = (Math.sin(time * 59) + Math.sin(time * 83) * 0.4) * 0.8
+ next.positionXLeft += x
+ next.positionXRight += x
+ next.positionYLeft += y
+ next.positionYRight += y
+ }
+
+ if (expression.bodyMotion === 'slowDrift') {
+ next.headX += smoothNoise(elapsedMs, 0, seed, 2600) * 0.8
+ next.headY += smoothNoise(elapsedMs, 1, seed, 3300) * 1.15
+ next.headZ += smoothNoise(elapsedMs, 2, seed, 4100) * 0.45
+ } else if (expression.bodyMotion === 'shake') {
+ const time = elapsedMs / 1000
+ next.headX += (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.15
+ next.headY += (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.35
+ next.headZ += Math.sin(time * 43) * 0.7
+ }
+
+ return next
+}
diff --git a/src/avatars.ts b/src/avatars.ts
new file mode 100644
index 0000000..4d1ff7f
--- /dev/null
+++ b/src/avatars.ts
@@ -0,0 +1,211 @@
+import { parseAvatarBody, type AvatarBody } from './body'
+import { defaultExpression, initialExpressions } from './presets'
+import { surfacePresets } from './surfaces'
+import type { Expression } from './geometry'
+import { isBodyMotion, isEyeMotion } from './ambientMotion'
+import { normalizeSequencesForExpressions, parseSequences, type AvatarSequence } from './sequences'
+
+export type AvatarBehaviorLibrary = {
+ expressions: Expression[]
+ sequences: AvatarSequence[]
+}
+
+export type StudioAvatar = {
+ id: string
+ name: string
+ body: AvatarBody
+ colors: AvatarColors
+ eyes: AvatarEyeDefaults
+ behavior?: AvatarBehaviorLibrary
+}
+
+export type AvatarColors = { body: string; eyes: string }
+export type AvatarEyeDefaults = Pick<
+ Expression,
+ | 'widthLeft'
+ | 'widthRight'
+ | 'heightLeft'
+ | 'heightRight'
+ | 'spacing'
+ | 'positionXLeft'
+ | 'positionXRight'
+ | 'positionYLeft'
+ | 'positionYRight'
+ | 'leftAngle'
+ | 'rightAngle'
+>
+export const defaultAvatarColors: AvatarColors = { body: '#5b7fe5', eyes: '#111316' }
+export const defaultAvatarEyes: AvatarEyeDefaults = {
+ widthLeft: defaultExpression.widthLeft,
+ widthRight: defaultExpression.widthRight,
+ heightLeft: defaultExpression.heightLeft,
+ heightRight: defaultExpression.heightRight,
+ spacing: defaultExpression.spacing,
+ positionXLeft: defaultExpression.positionXLeft,
+ positionXRight: defaultExpression.positionXRight,
+ positionYLeft: defaultExpression.positionYLeft,
+ positionYRight: defaultExpression.positionYRight,
+ leftAngle: defaultExpression.leftAngle,
+ rightAngle: defaultExpression.rightAngle,
+}
+const hexColor = /^#[0-9a-f]{6}$/i
+const parseColors = (value: unknown): AvatarColors => {
+ const candidate = value as Partial | null
+ return {
+ body:
+ typeof candidate?.body === 'string' && hexColor.test(candidate.body)
+ ? candidate.body
+ : defaultAvatarColors.body,
+ eyes:
+ typeof candidate?.eyes === 'string' && hexColor.test(candidate.eyes)
+ ? candidate.eyes
+ : defaultAvatarColors.eyes,
+ }
+}
+
+const eyeDefaultFields = Object.keys(defaultAvatarEyes) as (keyof AvatarEyeDefaults)[]
+export const parseAvatarEyeDefaults = (value: unknown): AvatarEyeDefaults => {
+ const candidate = value as Partial | null
+ const parsed = { ...defaultAvatarEyes }
+ eyeDefaultFields.forEach(field => {
+ const stored = candidate?.[field]
+ if (typeof stored === 'number' && Number.isFinite(stored)) parsed[field] = stored
+ })
+ return parsed
+}
+
+export const applyAvatarEyeDefaults = (
+ expression: Expression,
+ eyes: AvatarEyeDefaults = defaultAvatarEyes
+): Expression => {
+ const result = { ...expression }
+ eyeDefaultFields.forEach(field => {
+ result[field] = expression[field] + eyes[field] - defaultAvatarEyes[field]
+ })
+ result.widthLeft = Math.max(10, result.widthLeft)
+ result.widthRight = Math.max(10, result.widthRight)
+ result.heightLeft = Math.max(10, result.heightLeft)
+ result.heightRight = Math.max(10, result.heightRight)
+ return result
+}
+
+export type AvatarLibrary = {
+ activeAvatarId: string
+ avatars: StudioAvatar[]
+}
+
+const cloneExpressions = (expressions: Expression[]) => expressions.map(item => ({ ...item }))
+export const parseExpressions = (value: unknown): Expression[] => {
+ if (!Array.isArray(value) || !value.length) return cloneExpressions(initialExpressions)
+ return value.map((item, index) => {
+ if (!item || typeof item !== 'object') {
+ return { ...defaultExpression, id: `expression-${String(index).padStart(2, '0')}` }
+ }
+ const candidate = item as Partial
+ const storedEyeMotion = (item as { eyeMotion?: unknown }).eyeMotion
+ const storedBodyMotion = (item as { bodyMotion?: unknown }).bodyMotion
+ const parsed = Object.fromEntries(
+ Object.entries(defaultExpression).map(([field, fallback]) => {
+ if (field === 'id') {
+ return [
+ field,
+ typeof candidate.id === 'string' && candidate.id
+ ? candidate.id
+ : `expression-${String(index).padStart(2, '0')}`,
+ ]
+ }
+ const stored = candidate[field as keyof Expression]
+ return [field, typeof stored === 'number' && Number.isFinite(stored) ? stored : fallback]
+ })
+ ) as Expression
+ if (typeof candidate.bodyColor === 'string' && hexColor.test(candidate.bodyColor))
+ parsed.bodyColor = candidate.bodyColor
+ if (typeof candidate.eyeColor === 'string' && hexColor.test(candidate.eyeColor))
+ parsed.eyeColor = candidate.eyeColor
+ parsed.eyeMotion = isEyeMotion(storedEyeMotion) ? storedEyeMotion : defaultExpression.eyeMotion
+ parsed.bodyMotion = isBodyMotion(storedBodyMotion)
+ ? storedBodyMotion
+ : defaultExpression.bodyMotion
+ return parsed
+ })
+}
+
+const cloneSequences = (sequences: AvatarSequence[]) =>
+ sequences.map(sequence => ({
+ ...sequence,
+ steps: sequence.steps.map(step => ({ ...step })),
+ blink: { ...sequence.blink },
+ }))
+
+export const cloneAvatarBehavior = (behavior: AvatarBehaviorLibrary): AvatarBehaviorLibrary => ({
+ expressions: cloneExpressions(behavior.expressions),
+ sequences: cloneSequences(behavior.sequences),
+})
+
+export const resolveAvatarBehavior = (
+ avatar: StudioAvatar,
+ base: AvatarBehaviorLibrary
+): AvatarBehaviorLibrary => avatar.behavior ?? base
+
+const parseAvatarBehavior = (
+ value: unknown,
+ base: AvatarBehaviorLibrary
+): AvatarBehaviorLibrary | undefined => {
+ if (!value || typeof value !== 'object') return undefined
+ const candidate = value as Partial
+ if (!Array.isArray(candidate.expressions) || !candidate.expressions.length) return undefined
+ const expressions = parseExpressions(candidate.expressions)
+ const sequences = normalizeSequencesForExpressions(
+ Array.isArray(candidate.sequences)
+ ? parseSequences(candidate.sequences)
+ : cloneSequences(base.sequences),
+ expressions
+ )
+ return { expressions, sequences }
+}
+
+export const createAvatar = (name: string): StudioAvatar => ({
+ id: `avatar-${crypto.randomUUID()}`,
+ name: name.trim() || 'Nouvel avatar',
+ body: { primary: { ...surfacePresets.sphere }, nodes: [] },
+ colors: { ...defaultAvatarColors },
+ eyes: { ...defaultAvatarEyes },
+})
+
+export const parseAvatarLibrary = (
+ value: unknown,
+ fallback: AvatarLibrary,
+ baseBehavior: AvatarBehaviorLibrary
+): AvatarLibrary => {
+ try {
+ const parsed = value as Partial | null
+ if (!parsed || !Array.isArray(parsed.avatars) || !parsed.avatars.length) return fallback
+ const seenIds = new Set()
+ const avatars = parsed.avatars
+ .filter(avatar => {
+ if (!avatar || typeof avatar.id !== 'string' || typeof avatar.name !== 'string')
+ return false
+ if (seenIds.has(avatar.id)) return false
+ seenIds.add(avatar.id)
+ return true
+ })
+ .map(avatar => {
+ const behavior = parseAvatarBehavior(avatar.behavior, baseBehavior)
+ return {
+ id: avatar.id,
+ name: avatar.name,
+ body: parseAvatarBody(avatar.body, surfacePresets.sphere),
+ colors: parseColors(avatar.colors),
+ eyes: parseAvatarEyeDefaults(avatar.eyes),
+ ...(behavior ? { behavior } : {}),
+ }
+ })
+ if (!avatars.length) return fallback
+ const activeAvatarId = avatars.some(avatar => avatar.id === parsed.activeAvatarId)
+ ? parsed.activeAvatarId!
+ : avatars[0].id
+ return { activeAvatarId, avatars }
+ } catch {
+ return fallback
+ }
+}
diff --git a/src/body.ts b/src/body.ts
new file mode 100644
index 0000000..99db4c7
--- /dev/null
+++ b/src/body.ts
@@ -0,0 +1,117 @@
+import { surfaceLabels, surfacePresets, type SurfaceConfig, type SurfaceType } from './surfaces'
+
+export type BodyVector = readonly [number, number, number]
+
+export type BodyNode = {
+ id: string
+ name: string
+ surface: SurfaceConfig
+ position: BodyVector
+ rotation: BodyVector
+}
+
+export type AvatarBody = {
+ primary: SurfaceConfig
+ nodes: BodyNode[]
+}
+
+export const bodyPrimitiveTypes = [
+ 'sphere',
+ 'cube',
+ 'capsule',
+ 'cylinder',
+ 'cone',
+ 'diamond',
+] as const
+
+export const MAX_BODY_NODES = 16
+
+const allSurfaceTypes = Object.keys(surfacePresets) as SurfaceType[]
+const finite = (value: unknown): value is number =>
+ typeof value === 'number' && Number.isFinite(value)
+const vector = (value: unknown): value is BodyVector =>
+ Array.isArray(value) && value.length === 3 && value.every(finite)
+
+export const parseSurfaceConfig = (value: unknown, fallback: SurfaceConfig): SurfaceConfig => {
+ if (!value || typeof value !== 'object') return { ...fallback }
+ const candidate = value as Partial
+ const type =
+ candidate.type && allSurfaceTypes.includes(candidate.type) ? candidate.type : fallback.type
+ const preset = surfacePresets[type]
+ const numericFields = ['width', 'height', 'depth', 'roundness'] as const
+ if (numericFields.some(field => !finite(candidate[field]))) return { ...fallback }
+ if (candidate.morphRoundness !== undefined && !finite(candidate.morphRoundness))
+ return { ...fallback }
+ if (candidate.tipRoundness !== undefined && !finite(candidate.tipRoundness))
+ return { ...fallback }
+ if (candidate.baseRoundness !== undefined && !finite(candidate.baseRoundness))
+ return { ...fallback }
+ return { ...preset, ...candidate, type }
+}
+
+export const parseAvatarBody = (value: unknown, fallbackPrimary: SurfaceConfig): AvatarBody => {
+ if (!value || typeof value !== 'object') return { primary: fallbackPrimary, nodes: [] }
+ const candidate = value as Partial
+ const primary = parseSurfaceConfig(candidate.primary, fallbackPrimary)
+ const seenIds = new Set()
+ const nodes = Array.isArray(candidate.nodes)
+ ? candidate.nodes
+ .filter((node): node is BodyNode => {
+ if (!node || typeof node !== 'object') return false
+ const surface = (node as BodyNode).surface
+ const id = (node as BodyNode).id
+ if (id === 'primary' || seenIds.has(id)) return false
+ const valid = Boolean(
+ typeof (node as BodyNode).id === 'string' &&
+ id &&
+ typeof (node as BodyNode).name === 'string' &&
+ surface &&
+ bodyPrimitiveTypes.includes(surface.type as (typeof bodyPrimitiveTypes)[number]) &&
+ finite(surface.width) &&
+ finite(surface.height) &&
+ finite(surface.depth) &&
+ finite(surface.roundness) &&
+ vector((node as BodyNode).position) &&
+ vector((node as BodyNode).rotation)
+ )
+ if (valid) seenIds.add(id)
+ return valid
+ })
+ .slice(0, MAX_BODY_NODES)
+ .map(node => ({
+ ...node,
+ surface: parseSurfaceConfig(node.surface, surfacePresets[node.surface.type]),
+ }))
+ : []
+ return { primary, nodes }
+}
+
+export const createBodyNode = (
+ type: (typeof bodyPrimitiveTypes)[number],
+ index: number
+): BodyNode => {
+ const preset = surfacePresets[type]
+ const scale = 0.34
+ const side = index % 2 === 0 ? -1 : 1
+ return {
+ id: `shape-${crypto.randomUUID()}`,
+ name: `${surfaceLabels[type]} ${index + 1}`,
+ surface: {
+ ...preset,
+ width: preset.width * scale,
+ height: preset.height * scale,
+ depth: preset.depth * scale,
+ },
+ position: [side * 82, -72, -18],
+ rotation: [0, 0, 0],
+ }
+}
+
+export const duplicateBodyNode = (source: BodyNode): BodyNode => ({
+ ...source,
+ id: `shape-${crypto.randomUUID()}`,
+ name: `${source.name} copie`,
+ surface: { ...source.surface },
+ position: [source.position[0] + 14, source.position[1] + 14, source.position[2]],
+ rotation: [...source.rotation],
+})
diff --git a/src/canvasPreview.ts b/src/canvasPreview.ts
new file mode 100644
index 0000000..ddd213b
--- /dev/null
+++ b/src/canvasPreview.ts
@@ -0,0 +1,12 @@
+import { applyAvatarEyeDefaults, type AvatarEyeDefaults } from './avatars'
+import { type Expression } from './geometry'
+
+export type CanvasPreviewTarget = 'head' | 'eyes'
+
+export const resolveCanvasPreviewExpression = (
+ expression: Expression,
+ avatarEyes: AvatarEyeDefaults,
+ bodyEditing: boolean,
+ target: CanvasPreviewTarget
+) =>
+ bodyEditing && target === 'head' ? applyAvatarEyeDefaults(expression, avatarEyes) : expression
diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx
new file mode 100644
index 0000000..2b4967d
--- /dev/null
+++ b/src/components/ui/accordion.tsx
@@ -0,0 +1,57 @@
+import { Accordion as AccordionPrimitive } from '@base-ui/react/accordion'
+import { ChevronDown } from 'lucide-react'
+import type * as React from 'react'
+
+import { cn } from '../../lib/utils'
+
+function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
+ return (
+
+ )
+}
+
+function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
+ return (
+
+ )
+}
+
+function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) {
+ return (
+
+
+ {children}
+
+
+
+ )
+}
+
+function AccordionContent({ className, children, ...props }: AccordionPrimitive.Panel.Props) {
+ return (
+
+ {children}
+
+ )
+}
+
+export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }
diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..444f239
--- /dev/null
+++ b/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,71 @@
+import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react/alert-dialog'
+import type * as React from 'react'
+
+import { cn } from '../../lib/utils'
+import { Button } from './button'
+
+function AlertDialog(props: AlertDialogPrimitive.Root.Props) {
+ return
+}
+
+function AlertDialogTrigger(props: AlertDialogPrimitive.Trigger.Props) {
+ return
+}
+
+function AlertDialogContent({ className, ...props }: AlertDialogPrimitive.Popup.Props) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ return
+}
+
+function AlertDialogTitle({ className, ...props }: AlertDialogPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({ className, ...props }: AlertDialogPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
+ return
+}
+
+function AlertDialogCancel(props: AlertDialogPrimitive.Close.Props) {
+ return } {...props} />
+}
+
+function AlertDialogAction(props: React.ComponentProps) {
+ return
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+}
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 0000000..f1425f5
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -0,0 +1,35 @@
+import { cva, type VariantProps } from 'class-variance-authority'
+import type * as React from 'react'
+
+import { cn } from '../../lib/utils'
+
+const badgeVariants = cva(
+ 'inline-flex w-fit shrink-0 items-center justify-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap',
+ {
+ variants: {
+ variant: {
+ default: 'border-transparent bg-primary text-primary-foreground',
+ secondary: 'border-transparent bg-secondary text-secondary-foreground',
+ outline: 'border-border text-foreground',
+ },
+ },
+ defaultVariants: { variant: 'default' },
+ }
+)
+
+function Badge({
+ className,
+ variant = 'default',
+ ...props
+}: React.ComponentProps<'span'> & VariantProps) {
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
new file mode 100644
index 0000000..893ff3d
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -0,0 +1,44 @@
+import { Button as ButtonPrimitive } from '@base-ui/react/button'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '../../lib/utils'
+
+const buttonVariants = cva(
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground hover:bg-primary/80',
+ outline: 'border-border bg-background hover:bg-muted hover:text-foreground',
+ secondary: 'bg-secondary text-secondary-foreground hover:bg-muted',
+ ghost: 'hover:bg-muted hover:text-foreground',
+ destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20',
+ },
+ size: {
+ default: 'h-8 gap-1.5 px-2.5',
+ sm: 'h-7 gap-1 rounded-md px-2.5 text-xs',
+ lg: 'h-9 gap-1.5 px-3',
+ icon: 'size-8',
+ 'icon-sm': 'size-7 rounded-md',
+ },
+ },
+ defaultVariants: { variant: 'default', size: 'default' },
+ }
+)
+
+function Button({
+ className,
+ variant = 'default',
+ size = 'default',
+ ...props
+}: ButtonPrimitive.Props & VariantProps) {
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
new file mode 100644
index 0000000..0c92d40
--- /dev/null
+++ b/src/components/ui/card.tsx
@@ -0,0 +1,56 @@
+import type * as React from 'react'
+
+import { cn } from '../../lib/utils'
+
+function Card({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ return
+}
+
+function CardTitle({
+ as: Component = 'div',
+ className,
+ ...props
+}: React.ComponentProps<'div'> & { as?: 'div' | 'h2' | 'h3' }) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
+ return
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx
new file mode 100644
index 0000000..4436f55
--- /dev/null
+++ b/src/components/ui/context-menu.tsx
@@ -0,0 +1,62 @@
+import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react/context-menu'
+import type * as React from 'react'
+
+import { cn } from '../../lib/utils'
+
+function ContextMenu(props: ContextMenuPrimitive.Root.Props) {
+ return
+}
+
+function ContextMenuTrigger(props: ContextMenuPrimitive.Trigger.Props) {
+ return
+}
+
+function ContextMenuContent({ className, ...props }: ContextMenuPrimitive.Popup.Props) {
+ return (
+
+
+
+
+
+ )
+}
+
+function ContextMenuItem({
+ className,
+ variant = 'default',
+ ...props
+}: ContextMenuPrimitive.Item.Props & { variant?: 'default' | 'destructive' }) {
+ return (
+
+ )
+}
+
+function ContextMenuSeparator({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+export {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+}
diff --git a/src/components/ui/field.tsx b/src/components/ui/field.tsx
new file mode 100644
index 0000000..116146c
--- /dev/null
+++ b/src/components/ui/field.tsx
@@ -0,0 +1,63 @@
+import { cva, type VariantProps } from 'class-variance-authority'
+import type * as React from 'react'
+
+import { cn } from '../../lib/utils'
+
+const fieldVariants = cva('group/field flex w-full gap-3', {
+ variants: {
+ orientation: {
+ vertical: 'flex-col [&>*]:w-full',
+ horizontal: 'flex-row items-center',
+ responsive: 'flex-col @md:flex-row @md:items-center',
+ },
+ },
+ defaultVariants: { orientation: 'vertical' },
+})
+
+function Field({
+ className,
+ orientation = 'vertical',
+ ...props
+}: React.ComponentProps<'div'> & VariantProps) {
+ return (
+
+ )
+}
+
+function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
+ return (
+
+ )
+}
+
+export { Field, FieldDescription, FieldGroup, FieldTitle }
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..7b81e40
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import { Input as InputPrimitive } from '@base-ui/react/input'
+import type * as React from 'react'
+
+import { cn } from '../../lib/utils'
+
+function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
new file mode 100644
index 0000000..d8b369a
--- /dev/null
+++ b/src/components/ui/select.tsx
@@ -0,0 +1,70 @@
+import { Select as SelectPrimitive } from '@base-ui/react/select'
+import { Check, ChevronDown } from 'lucide-react'
+import type * as React from 'react'
+
+import { cn } from '../../lib/utils'
+
+function Select(
+ props: SelectPrimitive.Root.Props
+) {
+ return
+}
+
+function SelectTrigger({ className, children, ...props }: SelectPrimitive.Trigger.Props) {
+ return (
+
+ {children}
+ } />
+
+ )
+}
+
+function SelectValue(props: SelectPrimitive.Value.Props) {
+ return
+}
+
+function SelectContent({ className, children, ...props }: SelectPrimitive.Popup.Props) {
+ return (
+
+
+
+ {children}
+
+
+
+ )
+}
+
+function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue }
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 0000000..fbf4a4e
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -0,0 +1,24 @@
+import { Separator as SeparatorPrimitive } from '@base-ui/react/separator'
+import type * as React from 'react'
+
+import { cn } from '../../lib/utils'
+
+function Separator({
+ className,
+ orientation = 'horizontal',
+ ...props
+}: React.ComponentProps & { orientation?: 'horizontal' | 'vertical' }) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx
new file mode 100644
index 0000000..71de9c9
--- /dev/null
+++ b/src/components/ui/switch.tsx
@@ -0,0 +1,21 @@
+import { Switch as SwitchPrimitive } from '@base-ui/react/switch'
+import type * as React from 'react'
+
+import { cn } from '../../lib/utils'
+
+function Switch({ className, ...props }: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export { Switch }
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..ba73cca
--- /dev/null
+++ b/src/components/ui/tabs.tsx
@@ -0,0 +1,42 @@
+import { Tabs as TabsPrimitive } from '@base-ui/react/tabs'
+import type * as React from 'react'
+
+import { cn } from '../../lib/utils'
+
+function Tabs({ className, ...props }: TabsPrimitive.Root.Props) {
+ return (
+
+ )
+}
+
+function TabsList({ className, ...props }: TabsPrimitive.List.Props) {
+ return (
+
+ )
+}
+
+function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger }
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..a362fec
--- /dev/null
+++ b/src/components/ui/tooltip.tsx
@@ -0,0 +1,41 @@
+import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'
+
+import { cn } from '../../lib/utils'
+
+function TooltipProvider(props: TooltipPrimitive.Provider.Props) {
+ return
+}
+
+function Tooltip(props: TooltipPrimitive.Root.Props) {
+ return
+}
+
+function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) {
+ return
+}
+
+function TooltipContent({
+ className,
+ children,
+ side = 'top',
+ sideOffset = 6,
+ ...props
+}: TooltipPrimitive.Popup.Props & Pick) {
+ return (
+
+
+
+ {children}
+
+
+
+ )
+}
+
+export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
diff --git a/src/defaultStudioDocument.json b/src/defaultStudioDocument.json
new file mode 100644
index 0000000..49dc6c2
--- /dev/null
+++ b/src/defaultStudioDocument.json
@@ -0,0 +1,2146 @@
+{
+ "version": 2,
+ "library": {
+ "activeAvatarId": "strobi",
+ "avatars": [
+ {
+ "id": "strobi",
+ "name": "Strobi",
+ "body": {
+ "primary": {
+ "type": "sphere",
+ "width": 240,
+ "height": 240,
+ "depth": 240.03671875,
+ "roundness": 1
+ },
+ "nodes": []
+ },
+ "colors": {
+ "body": "#5b7fe5",
+ "eyes": "#111316"
+ },
+ "eyes": {
+ "widthLeft": 20,
+ "widthRight": 20,
+ "heightLeft": 50,
+ "heightRight": 50,
+ "spacing": 35,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": -7,
+ "positionYRight": -7,
+ "leftAngle": 0,
+ "rightAngle": 0
+ }
+ },
+ {
+ "id": "avatar-4fe2d1bd-cf46-4e5e-a62d-d6b60be519ed",
+ "name": "Freddy",
+ "body": {
+ "primary": {
+ "type": "cube",
+ "width": 174.732421875,
+ "height": 149.474609375,
+ "depth": 125.596484375,
+ "roundness": 0.7621875
+ },
+ "nodes": [
+ {
+ "id": "shape-1089630b-c718-4abb-9891-aa817d0a26c0",
+ "name": "Sphère 1",
+ "surface": {
+ "type": "sphere",
+ "width": 81.60000000000001,
+ "height": 81.60000000000001,
+ "depth": 37.96875000000001,
+ "roundness": 1
+ },
+ "position": [
+ -82,
+ -72,
+ -18
+ ],
+ "rotation": [
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "shape-6c5a8bdd-04b7-4581-8889-cf8e1af5246e",
+ "name": "Sphère 2",
+ "surface": {
+ "type": "sphere",
+ "width": 81.60000000000001,
+ "height": 81.60000000000001,
+ "depth": 37.024609375000004,
+ "roundness": 1
+ },
+ "position": [
+ 82,
+ -72,
+ -18
+ ],
+ "rotation": [
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "shape-68a52731-6b5b-487d-8508-dd3c6cebf781",
+ "name": "Cylindre 3",
+ "surface": {
+ "type": "cylinder",
+ "width": 59.483593750000004,
+ "height": 63.461328124999994,
+ "depth": 73.10000000000001,
+ "roundness": 1.5026171875,
+ "morphRoundness": 0.366171875
+ },
+ "position": [
+ -1.021484375,
+ -72,
+ -18
+ ],
+ "rotation": [
+ 0,
+ 0,
+ 0
+ ]
+ }
+ ]
+ },
+ "colors": {
+ "body": "#e6855c",
+ "eyes": "#ffffff"
+ },
+ "eyes": {
+ "widthLeft": 28.934879954481378,
+ "widthRight": 28.934879954481378,
+ "heightLeft": 60.839338555790405,
+ "heightRight": 60.839338555790405,
+ "spacing": 47.380859375,
+ "positionXLeft": -0.11992187500000001,
+ "positionXRight": -0.11992187500000001,
+ "positionYLeft": -4.310546875000002,
+ "positionYRight": -4.310546875000002,
+ "leftAngle": 0,
+ "rightAngle": 0
+ }
+ },
+ {
+ "id": "avatar-295e74a7-5d70-4d61-83d4-7beebb22bdd8",
+ "name": "Citrus",
+ "body": {
+ "primary": {
+ "type": "cone",
+ "width": 252.708984375,
+ "height": 274.9671875,
+ "depth": 225,
+ "roundness": 0,
+ "morphRoundness": 1.1473828125,
+ "tipRoundness": 0.743515625,
+ "baseRoundness": 1.34375
+ },
+ "nodes": []
+ },
+ "colors": {
+ "body": "#ffcf24",
+ "eyes": "#000000"
+ },
+ "eyes": {
+ "widthLeft": 20,
+ "widthRight": 20,
+ "heightLeft": 50,
+ "heightRight": 50,
+ "spacing": 35,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 22.954687500000002,
+ "positionYRight": 22.954687500000002,
+ "leftAngle": 0,
+ "rightAngle": 0
+ }
+ },
+ {
+ "id": "avatar-1786600724626",
+ "name": "Nova",
+ "body": {
+ "primary": {
+ "type": "capsule",
+ "width": 205,
+ "height": 270,
+ "depth": 205,
+ "roundness": 1
+ },
+ "nodes": []
+ },
+ "colors": {
+ "body": "#55b6c3",
+ "eyes": "#111316"
+ },
+ "eyes": {
+ "widthLeft": 13.26171875,
+ "widthRight": 13.26171875,
+ "heightLeft": 26.074181467301138,
+ "heightRight": 46.98897797482194,
+ "spacing": 19.765234375,
+ "positionXLeft": -12.965625,
+ "positionXRight": -12.965625,
+ "positionYLeft": 0.7968750000000009,
+ "positionYRight": -6.371484375,
+ "leftAngle": -7.4886718750000005,
+ "rightAngle": -7.796875
+ }
+ },
+ {
+ "id": "avatar-7874f78a-93ec-4536-a3b6-bb53ed744efd",
+ "name": "Grok bot",
+ "body": {
+ "primary": {
+ "type": "sphere",
+ "width": 240,
+ "height": 240,
+ "depth": 240,
+ "roundness": 1
+ },
+ "nodes": []
+ },
+ "colors": {
+ "body": "#000000",
+ "eyes": "#ffffff"
+ },
+ "eyes": {
+ "widthLeft": 20,
+ "widthRight": 20,
+ "heightLeft": 50,
+ "heightRight": 50,
+ "spacing": 35,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": -7,
+ "positionYRight": -7,
+ "leftAngle": 0,
+ "rightAngle": 0
+ }
+ },
+ {
+ "id": "avatar-1b2ee9c6-a6c5-4054-87e7-fec24f285269",
+ "name": "Sunee",
+ "body": {
+ "primary": {
+ "type": "sphere",
+ "width": 182.95728256225593,
+ "height": 185.5484375,
+ "depth": 100.01221191461767,
+ "roundness": 1
+ },
+ "nodes": [
+ {
+ "id": "shape-38130162-a15b-4fa8-a58b-9c74b07e1a25",
+ "name": "Sphère 1",
+ "surface": {
+ "type": "sphere",
+ "width": 60,
+ "height": 60,
+ "depth": 24.375287224264703,
+ "roundness": 1
+ },
+ "position": [
+ -94.48309236361106,
+ -95.44637036124786,
+ -10.398419675330803
+ ],
+ "rotation": [
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "shape-f987c6e1-02e3-4256-9053-655414890a3c",
+ "name": "Sphère 1 copie",
+ "surface": {
+ "type": "sphere",
+ "width": 59.57851562500001,
+ "height": 59.57851562500001,
+ "depth": 24.204057179245304,
+ "roundness": 1
+ },
+ "position": [
+ -6.440040491385305,
+ -133.984012454223,
+ -10.398419675330803
+ ],
+ "rotation": [
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "shape-0f31bd3d-2d53-4c9d-a5e0-d587d6ef9b8c",
+ "name": "Sphère 1 copie copie",
+ "surface": {
+ "type": "sphere",
+ "width": 81.60000000000001,
+ "height": 81.60000000000001,
+ "depth": 33.150390625,
+ "roundness": 1
+ },
+ "position": [
+ 89.09066821815593,
+ -114.30101031028101,
+ -10.398419675330803
+ ],
+ "rotation": [
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "shape-9a17a167-46fb-4871-9252-24abbb80b1ca",
+ "name": "Sphère 1 copie copie copie",
+ "surface": {
+ "type": "sphere",
+ "width": 70.12148437500001,
+ "height": 70.12148437500001,
+ "depth": 28.487188703873578,
+ "roundness": 1
+ },
+ "position": [
+ 132.2484739833418,
+ -11.704819633353354,
+ -10.398419675330803
+ ],
+ "rotation": [
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "shape-e6574431-f8b6-482f-89d0-c280a327461f",
+ "name": "Sphère 1 copie copie copie copie",
+ "surface": {
+ "type": "sphere",
+ "width": 61.89843750000001,
+ "height": 61.89843750000001,
+ "depth": 25.146536546594966,
+ "roundness": 1
+ },
+ "position": [
+ -138.13379284351342,
+ -1.1847860044189815,
+ -10.398419675330803
+ ],
+ "rotation": [
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "shape-5bc8f568-1cbb-4419-84bc-ce264b218a02",
+ "name": "Sphère 1 copie copie copie copie copie",
+ "surface": {
+ "type": "sphere",
+ "width": 61.11328125000001,
+ "height": 61.11328125000001,
+ "depth": 24.827563061433676,
+ "roundness": 1
+ },
+ "position": [
+ -103.29429634246448,
+ 92.4041125555865,
+ -10.398419675330803
+ ],
+ "rotation": [
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "shape-405e86d3-93c9-4de8-a15e-9ae1ba33c88c",
+ "name": "Sphère 1 copie copie copie copie copie copie",
+ "surface": {
+ "type": "sphere",
+ "width": 61.726171875000006,
+ "height": 61.726171875000006,
+ "depth": 25.076552811790915,
+ "roundness": 1
+ },
+ "position": [
+ -2.4878844479075983,
+ 133.06983674233183,
+ -10.398419675330803
+ ],
+ "rotation": [
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "shape-0abdf33f-450d-4125-9354-b02842084138",
+ "name": "Sphère 1 copie copie copie copie copie copie copie",
+ "surface": {
+ "type": "sphere",
+ "width": 64.116796875,
+ "height": 64.116796875,
+ "depth": 26.047755662132705,
+ "roundness": 1
+ },
+ "position": [
+ 109.54168183067505,
+ 83.98361858650364,
+ -10.398419675330803
+ ],
+ "rotation": [
+ 0,
+ 0,
+ 0
+ ]
+ }
+ ]
+ },
+ "colors": {
+ "body": "#e69a5c",
+ "eyes": "#111316"
+ },
+ "eyes": {
+ "widthLeft": 20,
+ "widthRight": 20,
+ "heightLeft": 50,
+ "heightRight": 50,
+ "spacing": 35,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": -7,
+ "positionYRight": -7,
+ "leftAngle": 0,
+ "rightAngle": 0
+ }
+ },
+ {
+ "id": "avatar-b6362e59-81a3-4334-a399-a721b23cf553",
+ "name": "Kirby",
+ "body": {
+ "primary": {
+ "type": "sphere",
+ "width": 240,
+ "height": 240,
+ "depth": 240,
+ "roundness": 1
+ },
+ "nodes": [
+ {
+ "id": "shape-2f26b4c0-61de-4fde-ae09-c4595cdaab59",
+ "name": "Sphère 1",
+ "surface": {
+ "type": "sphere",
+ "width": 108.11015625000002,
+ "height": 81.60000000000001,
+ "depth": 81.60000000000001,
+ "roundness": 1
+ },
+ "position": [
+ -103.30437876033604,
+ 30.4449714479682,
+ -9.784765625
+ ],
+ "rotation": [
+ 0,
+ 0,
+ -14.843359375
+ ]
+ },
+ {
+ "id": "shape-3cd1a09e-b9a8-4271-95b1-4c21a09a6580",
+ "name": "Sphère 1 copie",
+ "surface": {
+ "type": "sphere",
+ "width": 108.11015625000002,
+ "height": 81.60000000000001,
+ "depth": 81.60000000000001,
+ "roundness": 1
+ },
+ "position": [
+ 98.15429266544173,
+ 32.55003025735345,
+ -9.784765625
+ ],
+ "rotation": [
+ 0,
+ 0,
+ 15.175000000000004
+ ]
+ }
+ ]
+ },
+ "colors": {
+ "body": "#ffc2e9",
+ "eyes": "#3e4e65"
+ },
+ "eyes": {
+ "widthLeft": 20,
+ "widthRight": 20,
+ "heightLeft": 60.473828125,
+ "heightRight": 60.473828125,
+ "spacing": 28.74921875,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": -7,
+ "positionYRight": -7,
+ "leftAngle": 0,
+ "rightAngle": 0
+ }
+ },
+ {
+ "id": "avatar-fafdaf4d-2071-41d6-9d42-7d34670956f0",
+ "name": "Cloudee",
+ "body": {
+ "primary": {
+ "type": "sphere",
+ "width": 159.787109375,
+ "height": 159.787109375,
+ "depth": 159.77982741038028,
+ "roundness": 1
+ },
+ "nodes": [
+ {
+ "id": "shape-3e98b39e-4765-4895-9309-3d0dd55bd316",
+ "name": "Sphère 1",
+ "surface": {
+ "type": "sphere",
+ "width": 81.60000000000001,
+ "height": 81.60000000000001,
+ "depth": 81.60000000000001,
+ "roundness": 1
+ },
+ "position": [
+ -54.211163573292225,
+ -19.983270576375716,
+ -18
+ ],
+ "rotation": [
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "shape-52d58f1c-c8da-4077-8d29-454907ad8847",
+ "name": "Sphère 1 copie",
+ "surface": {
+ "type": "sphere",
+ "width": 108.64140625000002,
+ "height": 87.10000590491492,
+ "depth": 90.99923895827905,
+ "roundness": 1
+ },
+ "position": [
+ -64.06931629506641,
+ 18.35102511266605,
+ -18
+ ],
+ "rotation": [
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "shape-d4b4e8ad-8625-488d-920c-c497da226f9f",
+ "name": "Sphère 1 copie copie",
+ "surface": {
+ "type": "sphere",
+ "width": 97.79101562500003,
+ "height": 96.8381380478929,
+ "depth": 89.56416057242059,
+ "roundness": 1
+ },
+ "position": [
+ 61.23234219342984,
+ 18.962019686907013,
+ -18
+ ],
+ "rotation": [
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "id": "shape-56ea10ed-1202-4b78-9968-96f870f633f7",
+ "name": "Sphère 1 copie",
+ "surface": {
+ "type": "sphere",
+ "width": 94.34892531002268,
+ "height": 94.3451962078319,
+ "depth": 100.53515625000001,
+ "roundness": 1
+ },
+ "position": [
+ 41.48876642638391,
+ -37.63883372407813,
+ -17.99133043155456
+ ],
+ "rotation": [
+ 0,
+ 0,
+ 0
+ ]
+ }
+ ]
+ },
+ "colors": {
+ "body": "#c9cbcf",
+ "eyes": "#111316"
+ },
+ "eyes": {
+ "widthLeft": 13.942812499999993,
+ "widthRight": 13.942812499999993,
+ "heightLeft": 45.4203125,
+ "heightRight": 45.4203125,
+ "spacing": 12.757031249999997,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": -1.0691406249999993,
+ "positionYRight": -1.0691406249999993,
+ "leftAngle": 0,
+ "rightAngle": 0
+ }
+ },
+ {
+ "id": "avatar-2739f2c2-a5b4-45d9-8915-c9d6101d4d3b",
+ "name": "Cubee",
+ "body": {
+ "primary": {
+ "type": "cube",
+ "width": 191.49921875,
+ "height": 191.49921875,
+ "depth": 171.95848214285726,
+ "roundness": 0.73265625
+ },
+ "nodes": []
+ },
+ "colors": {
+ "body": "#e65c5c",
+ "eyes": "#111316"
+ },
+ "eyes": {
+ "widthLeft": 19.975390625,
+ "widthRight": 28.008984374999997,
+ "heightLeft": 29.983984375,
+ "heightRight": 36.986718749999994,
+ "spacing": 16.812109375,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": -7,
+ "positionYRight": -7,
+ "leftAngle": 0,
+ "rightAngle": 0
+ }
+ },
+ {
+ "id": "avatar-4b9ea0c1-286f-4aa1-b053-61fcc416ba7e",
+ "name": "Onee",
+ "body": {
+ "primary": {
+ "type": "cone",
+ "width": 250,
+ "height": 182.006640625,
+ "depth": 225,
+ "roundness": 0,
+ "morphRoundness": 1.2003515624999999,
+ "tipRoundness": 2,
+ "baseRoundness": 2
+ },
+ "nodes": []
+ },
+ "colors": {
+ "body": "#dbe2f5",
+ "eyes": "#111316"
+ },
+ "eyes": {
+ "widthLeft": 13.389062500000001,
+ "widthRight": 19.10390625,
+ "heightLeft": 44.011328125000006,
+ "heightRight": 37.198828125000006,
+ "spacing": 21.41328125,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 18.613671875,
+ "positionYRight": 18.613671875,
+ "leftAngle": 6.980078125,
+ "rightAngle": -7.012890625000001
+ }
+ }
+ ]
+ },
+ "expressions": [
+ {
+ "id": "expression-00",
+ "headX": 7.3,
+ "headY": 27.8,
+ "headZ": -16.1,
+ "widthLeft": 22.501171874999997,
+ "widthRight": 22.501171874999997,
+ "heightLeft": 42.377734374999996,
+ "heightRight": 42.377734374999996,
+ "spacing": 54.3,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": -20.5,
+ "positionYRight": -20.5,
+ "leftAngle": 0,
+ "rightAngle": 0,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-01",
+ "headX": -15.057812500000004,
+ "headY": 0.14296874999999964,
+ "headZ": -14.549218750000001,
+ "widthLeft": 22.401171875,
+ "widthRight": 22.401171875,
+ "heightLeft": 54.5703125,
+ "heightRight": 54.5703125,
+ "spacing": 57.7,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": 0,
+ "rightAngle": 0,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-05",
+ "headX": -16.528515625,
+ "headY": -3.7679687499999996,
+ "headZ": -13.7296875,
+ "widthLeft": 23.090625,
+ "widthRight": 49.924609375,
+ "heightLeft": 57.6796875,
+ "heightRight": 12.431640625,
+ "spacing": 56.3,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": 0,
+ "rightAngle": 0,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-06",
+ "headX": -4.232421875000001,
+ "headY": 14.362109375000003,
+ "headZ": 11.204296875,
+ "widthLeft": 22.066796874999998,
+ "widthRight": 22.066796874999998,
+ "heightLeft": 39.59921875,
+ "heightRight": 39.59921875,
+ "spacing": 50.9,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": 0,
+ "rightAngle": 0,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-12",
+ "headX": -19.20859375,
+ "headY": 15.2,
+ "headZ": 11.8,
+ "widthLeft": 52.084765625,
+ "widthRight": 53.11410881916995,
+ "heightLeft": 51.467159708498066,
+ "heightRight": 52.187699944416956,
+ "spacing": 69.5,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": 0,
+ "rightAngle": 0,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-03",
+ "headX": 2.9468749999999986,
+ "headY": -16.051171875,
+ "headZ": -20.916015625,
+ "widthLeft": 51.68336723153048,
+ "widthRight": 51.68336723153048,
+ "heightLeft": 51.74054108796297,
+ "heightRight": 51.74054108796297,
+ "spacing": 70.9,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": 0,
+ "rightAngle": 0,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-04",
+ "headX": 3.4,
+ "headY": 13.22578125,
+ "headZ": 8.976953125,
+ "widthLeft": 51.775,
+ "widthRight": 51.775,
+ "heightLeft": 13.02734375,
+ "heightRight": 13.02734375,
+ "spacing": 63.872265625,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": 0,
+ "rightAngle": 0,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-07",
+ "headX": 8.063671874999999,
+ "headY": 17.626562500000002,
+ "headZ": -11.116796874999999,
+ "widthLeft": 20.908203124999996,
+ "widthRight": 20.908203124999996,
+ "heightLeft": 40.40078125,
+ "heightRight": 40.40078125,
+ "spacing": 52.059765625,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": -30.865625,
+ "rightAngle": 28.781640625,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-08",
+ "headX": -12.303515625,
+ "headY": -17.601171875,
+ "headZ": 5.9109375,
+ "widthLeft": 20.605859374999994,
+ "widthRight": 20.605859374999994,
+ "heightLeft": 47.769921874999994,
+ "heightRight": 47.769921874999994,
+ "spacing": 54.9,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": 23.523046875000002,
+ "rightAngle": -24.042578125000002,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-09",
+ "headX": -20.058203125,
+ "headY": 12.607421875,
+ "headZ": -12.7,
+ "widthLeft": 42.5,
+ "widthRight": 22.1,
+ "heightLeft": 41.8,
+ "heightRight": 22.2,
+ "spacing": 61.7,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": 0,
+ "rightAngle": 0,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-10",
+ "headX": 1.43359375,
+ "headY": 6.194140624999999,
+ "headZ": 10.56015625,
+ "widthLeft": 23.836718749999996,
+ "widthRight": 23.836718749999996,
+ "heightLeft": 58.130078125,
+ "heightRight": 58.130078125,
+ "spacing": 56.8,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": 0,
+ "rightAngle": 0,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-11",
+ "headX": -2.092968750000001,
+ "headY": -15.899609374999999,
+ "headZ": -14.469921875,
+ "widthLeft": 34.20086765973213,
+ "widthRight": 34.20086765973213,
+ "heightLeft": 85.330859375,
+ "heightRight": 83.17775668160692,
+ "spacing": 59.414453125,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": 0,
+ "rightAngle": 0,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-13",
+ "headX": -8.752343750000001,
+ "headY": -8.743359375,
+ "headZ": -10.773828125000001,
+ "widthLeft": 56.133984375,
+ "widthRight": 56.133984375,
+ "heightLeft": 15.5,
+ "heightRight": 15.15546875,
+ "spacing": 69.276171875,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": 0,
+ "rightAngle": 0,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-02",
+ "headX": -15.287109375000002,
+ "headY": 15.006640625,
+ "headZ": 12.787890625,
+ "widthLeft": 31.253906249999996,
+ "widthRight": 31.253906249999996,
+ "heightLeft": 76.720703125,
+ "heightRight": 76.720703125,
+ "spacing": 68.7,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": 0,
+ "rightAngle": 0,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-14",
+ "headX": 3.5292968750000004,
+ "headY": -7.0765625,
+ "headZ": 9.830078125,
+ "widthLeft": 24.306250000000002,
+ "widthRight": 48.92421875000001,
+ "heightLeft": 59.281640624999994,
+ "heightRight": 13.408203124999996,
+ "spacing": 62.218359375,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": 0,
+ "rightAngle": 0,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-15",
+ "headX": 0.31914062500000184,
+ "headY": 35.307421874999996,
+ "headZ": -10.904296875,
+ "widthLeft": 22.4609375,
+ "widthRight": 22.4609375,
+ "heightLeft": 39.820703125,
+ "heightRight": 39.820703125,
+ "spacing": 53.9,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": 0,
+ "rightAngle": 0,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-16",
+ "headX": -14.750781250000001,
+ "headY": -19.350000000000005,
+ "headZ": 5.631640624999998,
+ "widthLeft": 19.602343750000003,
+ "widthRight": 19.602343750000003,
+ "heightLeft": 48.63984375,
+ "heightRight": 48.63984375,
+ "spacing": 55.1,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": -27.606640625,
+ "rightAngle": 26.1484375,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-17",
+ "headX": -4.3953125,
+ "headY": 14.07265625,
+ "headZ": -16.126171874999997,
+ "widthLeft": 19.045145681988206,
+ "widthRight": 19.045145681988206,
+ "heightLeft": 43.370703125,
+ "heightRight": 43.370703125,
+ "spacing": 51.731249999999996,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": 26.2921875,
+ "rightAngle": -20.249218750000004,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-18",
+ "headX": 6.585546875,
+ "headY": 4.737109375000001,
+ "headZ": 12.840234374999998,
+ "widthLeft": 42.1,
+ "widthRight": 22.2,
+ "heightLeft": 41.7,
+ "heightRight": 22.1,
+ "spacing": 60.4,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": 0,
+ "rightAngle": 0,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-19",
+ "headX": -6.077734375000001,
+ "headY": -11.03515625,
+ "headZ": -13.965625000000001,
+ "widthLeft": 23.045703125,
+ "widthRight": 23.045703125,
+ "heightLeft": 58.68515625,
+ "heightRight": 58.68515625,
+ "spacing": 56.2,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": 0,
+ "rightAngle": 0,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-20",
+ "headX": -17.127734375000003,
+ "headY": 18.070703124999998,
+ "headZ": 13.891796875,
+ "widthLeft": 35.452734375,
+ "widthRight": 35.452734375,
+ "heightLeft": 79.104296875,
+ "heightRight": 79.104296875,
+ "spacing": 70.8,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": 0,
+ "rightAngle": 0,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-21",
+ "headX": -5.428125,
+ "headY": -11.71328125,
+ "headZ": -13.472265625000002,
+ "widthLeft": 51.4,
+ "widthRight": 50.5,
+ "heightLeft": 50.1,
+ "heightRight": 49.4,
+ "spacing": 69,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": 0,
+ "rightAngle": 0,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-22",
+ "headX": 10.292578125,
+ "headY": 3.39921875,
+ "headZ": 7.583203125,
+ "widthLeft": 55.672265625,
+ "widthRight": 55.672265625,
+ "heightLeft": 14.616015625,
+ "heightRight": 14.616015625,
+ "spacing": 68.416796875,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": 0,
+ "rightAngle": 0,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-23",
+ "headX": -17.8,
+ "headY": 10,
+ "headZ": -10.894921875,
+ "widthLeft": 23.969921875,
+ "widthRight": 53.56328125,
+ "heightLeft": 55.89296875,
+ "heightRight": 13.333593750000002,
+ "spacing": 59.94375,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": -9.8,
+ "positionYRight": -9.8,
+ "leftAngle": 0,
+ "rightAngle": 0,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-24",
+ "headX": 7.131640624999998,
+ "headY": 7.7828124999999995,
+ "headZ": 3.935546874999999,
+ "widthLeft": 21.5,
+ "widthRight": 23.2,
+ "heightLeft": 32,
+ "heightRight": 33.5,
+ "spacing": 51.2,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 40,
+ "positionYRight": 40,
+ "leftAngle": 0,
+ "rightAngle": 0,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "none"
+ },
+ {
+ "id": "expression-3d2bed26-f97c-477d-922f-77600cb10e92",
+ "headX": 10.473974503042374,
+ "headY": 5.087293619785961,
+ "headZ": 4.698252132317348,
+ "widthLeft": 27.126562499999995,
+ "widthRight": 27.126562499999995,
+ "heightLeft": 63.028125,
+ "heightRight": 63.028125,
+ "spacing": 68.7,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": -36.244531249999994,
+ "rightAngle": 27.730078125000002,
+ "perspective": 1,
+ "eyeMotion": "none",
+ "bodyMotion": "shake",
+ "bodyColor": "#ba3636",
+ "eyeColor": "#610000"
+ },
+ {
+ "id": "expression-5220eaee-32fe-4bd8-ad31-432189534cc8",
+ "headX": -12.303515625,
+ "headY": -17.601171875,
+ "headZ": 5.9109375,
+ "widthLeft": 20.605859374999994,
+ "widthRight": 20.605859374999994,
+ "heightLeft": 47.769921874999994,
+ "heightRight": 47.769921874999994,
+ "spacing": 54.9,
+ "positionXLeft": 0,
+ "positionXRight": 0,
+ "positionYLeft": 0,
+ "positionYRight": 0,
+ "leftAngle": 23.523046875000002,
+ "rightAngle": -24.042578125000002,
+ "perspective": 1,
+ "eyeMotion": "shake",
+ "bodyMotion": "slowDrift",
+ "bodyColor": "#adc3ff"
+ }
+ ],
+ "sequences": [
+ {
+ "id": "sleeping",
+ "name": "sleeping",
+ "group": "Cycle de vie",
+ "description": "Yeux presque fermés, respiration lente et expression de sommeil.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "sleeping-step-0",
+ "expressionId": "expression-13",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "sleeping-step-1",
+ "expressionId": "expression-22",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "sleeping-step-2",
+ "expressionId": "expression-04",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 4800,
+ "minIntervalMs": 6500,
+ "maxIntervalMs": 9500,
+ "durationMs": 420
+ }
+ },
+ {
+ "id": "waking",
+ "name": "waking",
+ "group": "Cycle de vie",
+ "description": "Séquence courte de réveil avant retour vers une expression neutre.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "waking-step-0",
+ "expressionId": "expression-13",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 1200,
+ "minIntervalMs": 1800,
+ "maxIntervalMs": 3600,
+ "durationMs": 220
+ }
+ },
+ {
+ "id": "idle",
+ "name": "idle",
+ "group": "Cycle de vie",
+ "description": "Micro-mouvements lents, expressions 00 et 08, clignement rare.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "idle-step-0",
+ "expressionId": "expression-00",
+ "holdMs": 5200,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "idle-step-1",
+ "expressionId": "expression-08",
+ "holdMs": 5200,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2600,
+ "minIntervalMs": 3400,
+ "maxIntervalMs": 6200,
+ "durationMs": 280
+ }
+ },
+ {
+ "id": "listening",
+ "name": "listening",
+ "group": "Cycle de vie",
+ "description": "Expressions 10, 01 et 19, regard stable et clignement attentif.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "listening-step-0",
+ "expressionId": "expression-10",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "listening-step-1",
+ "expressionId": "expression-01",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "listening-step-2",
+ "expressionId": "expression-19",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 3200,
+ "minIntervalMs": 4800,
+ "maxIntervalMs": 7200,
+ "durationMs": 240
+ }
+ },
+ {
+ "id": "thinking",
+ "name": "thinking",
+ "group": "Cycle de vie",
+ "description": "Regard haut et latéral, expressions asymétriques et changements fréquents.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "thinking-step-0",
+ "expressionId": "expression-08",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "thinking-step-1",
+ "expressionId": "expression-16",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "thinking-step-2",
+ "expressionId": "expression-14",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "thinking-step-3",
+ "expressionId": "expression-17",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "thinking-step-4",
+ "expressionId": "expression-05",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ }
+ },
+ {
+ "id": "searching",
+ "name": "searching",
+ "group": "Cycle de vie",
+ "description": "Balayage rapide et changements très fréquents.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "searching-step-0",
+ "expressionId": "expression-15",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "searching-step-1",
+ "expressionId": "expression-09",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "searching-step-2",
+ "expressionId": "expression-03",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "searching-step-3",
+ "expressionId": "expression-20",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "searching-step-4",
+ "expressionId": "expression-12",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "searching-step-5",
+ "expressionId": "expression-18",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ }
+ },
+ {
+ "id": "working",
+ "name": "working",
+ "group": "Cycle de vie",
+ "description": "Rythme régulier et expressions concentrées.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "working-step-0",
+ "expressionId": "expression-07",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "working-step-1",
+ "expressionId": "expression-16",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "working-step-2",
+ "expressionId": "expression-11",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "working-step-3",
+ "expressionId": "expression-10",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ }
+ },
+ {
+ "id": "excited",
+ "name": "excited",
+ "group": "Réactions",
+ "description": "Grandes expressions et transitions rapides.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "excited-step-0",
+ "expressionId": "expression-02",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "excited-step-1",
+ "expressionId": "expression-17",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "excited-step-2",
+ "expressionId": "expression-21",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "excited-step-3",
+ "expressionId": "expression-03",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "excited-step-4",
+ "expressionId": "expression-11",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 1200,
+ "minIntervalMs": 1800,
+ "maxIntervalMs": 3600,
+ "durationMs": 220
+ }
+ },
+ {
+ "id": "bored",
+ "name": "bored",
+ "group": "Réactions",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "bored-step-0",
+ "expressionId": "expression-04",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "bored-step-1",
+ "expressionId": "expression-22",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "bored-step-2",
+ "expressionId": "expression-00",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 4800,
+ "minIntervalMs": 6500,
+ "maxIntervalMs": 9500,
+ "durationMs": 420
+ }
+ },
+ {
+ "id": "suspicious",
+ "name": "suspicious",
+ "group": "Réactions",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "suspicious-step-0",
+ "expressionId": "expression-14",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "suspicious-step-1",
+ "expressionId": "expression-05",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "suspicious-step-2",
+ "expressionId": "expression-23",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ }
+ },
+ {
+ "id": "angry",
+ "name": "angry",
+ "group": "Réactions",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "angry-step-0",
+ "expressionId": "expression-07",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "angry-step-1",
+ "expressionId": "expression-16",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ }
+ },
+ {
+ "id": "drowsy",
+ "name": "drowsy",
+ "group": "Réactions",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "drowsy-step-0",
+ "expressionId": "expression-04",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "drowsy-step-1",
+ "expressionId": "expression-22",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "drowsy-step-2",
+ "expressionId": "expression-13",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 4800,
+ "minIntervalMs": 6500,
+ "maxIntervalMs": 9500,
+ "durationMs": 420
+ }
+ },
+ {
+ "id": "happy",
+ "name": "happy",
+ "group": "Réactions",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "happy-step-0",
+ "expressionId": "expression-02",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "happy-step-1",
+ "expressionId": "expression-11",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "happy-step-2",
+ "expressionId": "expression-17",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "happy-step-3",
+ "expressionId": "expression-19",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ }
+ },
+ {
+ "id": "curious",
+ "name": "curious",
+ "group": "Réactions",
+ "description": "Inclinaisons et forte asymétrie.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "curious-step-0",
+ "expressionId": "expression-03",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "curious-step-1",
+ "expressionId": "expression-21",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "curious-step-2",
+ "expressionId": "expression-00",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "curious-step-3",
+ "expressionId": "expression-15",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ }
+ },
+ {
+ "id": "confused",
+ "name": "confused",
+ "group": "Réactions",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "confused-step-0",
+ "expressionId": "expression-14",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "confused-step-1",
+ "expressionId": "expression-05",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "confused-step-2",
+ "expressionId": "expression-08",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ }
+ },
+ {
+ "id": "surprised",
+ "name": "surprised",
+ "group": "Réactions",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "surprised-step-0",
+ "expressionId": "expression-03",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "surprised-step-1",
+ "expressionId": "expression-21",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 1200,
+ "minIntervalMs": 1800,
+ "maxIntervalMs": 3600,
+ "durationMs": 220
+ }
+ },
+ {
+ "id": "proud",
+ "name": "proud",
+ "group": "Réactions",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "proud-step-0",
+ "expressionId": "expression-15",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "proud-step-1",
+ "expressionId": "expression-08",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "proud-step-2",
+ "expressionId": "expression-02",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ }
+ },
+ {
+ "id": "shy",
+ "name": "shy",
+ "group": "Réactions",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "shy-step-0",
+ "expressionId": "expression-00",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "shy-step-1",
+ "expressionId": "expression-24",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "shy-step-2",
+ "expressionId": "expression-13",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ }
+ },
+ {
+ "id": "sad",
+ "name": "sad",
+ "group": "Réactions",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "sad-step-0",
+ "expressionId": "expression-04",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "sad-step-1",
+ "expressionId": "expression-13",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "sad-step-2",
+ "expressionId": "expression-22",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 4800,
+ "minIntervalMs": 6500,
+ "maxIntervalMs": 9500,
+ "durationMs": 420
+ }
+ },
+ {
+ "id": "laughing",
+ "name": "laughing",
+ "group": "Réactions",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "laughing-step-0",
+ "expressionId": "expression-02",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "laughing-step-1",
+ "expressionId": "expression-11",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "laughing-step-2",
+ "expressionId": "expression-17",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 1200,
+ "minIntervalMs": 1800,
+ "maxIntervalMs": 3600,
+ "durationMs": 220
+ }
+ },
+ {
+ "id": "scared",
+ "name": "scared",
+ "group": "Réactions",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "scared-step-0",
+ "expressionId": "expression-03",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "scared-step-1",
+ "expressionId": "expression-21",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 1200,
+ "minIntervalMs": 1800,
+ "maxIntervalMs": 3600,
+ "durationMs": 220
+ }
+ },
+ {
+ "id": "playful",
+ "name": "playful",
+ "group": "Réactions",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "playful-step-0",
+ "expressionId": "expression-02",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "playful-step-1",
+ "expressionId": "expression-17",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "playful-step-2",
+ "expressionId": "expression-11",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "playful-step-3",
+ "expressionId": "expression-08",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ }
+ },
+ {
+ "id": "celebrate",
+ "name": "celebrate",
+ "group": "Réactions",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "builtIn": true,
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "id": "celebrate-step-0",
+ "expressionId": "expression-02",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "celebrate-step-1",
+ "expressionId": "expression-08",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "id": "celebrate-step-2",
+ "expressionId": "expression-17",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 1200,
+ "minIntervalMs": 1800,
+ "maxIntervalMs": 3600,
+ "durationMs": 220
+ }
+ }
+ ],
+ "playback": {
+ "stateId": "proud",
+ "playing": true
+ }
+}
diff --git a/src/exporter.ts b/src/exporter.ts
new file mode 100644
index 0000000..92ce246
--- /dev/null
+++ b/src/exporter.ts
@@ -0,0 +1,547 @@
+import { applyAvatarEyeDefaults, type StudioAvatar } from './avatars'
+import type { Expression } from './geometry'
+import { translateStudioText, type StudioLanguage } from './i18n'
+import { proceduralBrowserRuntime } from './proceduralBrowserRuntime'
+import type { AvatarSequence } from './sequences'
+import { standaloneEngineSource } from './standaloneEngine.generated'
+import { createStoredZip } from './storedZip'
+
+export type AvatarExportAnimation = Pick<
+ AvatarSequence,
+ 'name' | 'description' | 'playbackMode' | 'blink'
+> & {
+ steps: Pick<
+ AvatarSequence['steps'][number],
+ 'expressionId' | 'holdMs' | 'transitionMs' | 'transition'
+ >[]
+}
+
+export type AvatarExportPayload = {
+ version: 1
+ avatar: {
+ name: string
+ surface: StudioAvatar['body']['primary']
+ bodyNodes: StudioAvatar['body']['nodes']
+ colors: StudioAvatar['colors']
+ }
+ expressions: Record
+ animations: Record
+}
+
+const animationKey = (animation: AvatarSequence, used: Set) => {
+ const source = animation.builtIn ? animation.id : animation.name
+ const base =
+ source
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '') || 'animation'
+ let key = base
+ let suffix = 2
+ while (used.has(key)) key = `${base}-${suffix++}`
+ used.add(key)
+ return key
+}
+
+export const createAvatarExportPayload = (
+ avatar: StudioAvatar,
+ expressions: Expression[],
+ selectedAnimations: AvatarSequence[]
+): AvatarExportPayload => {
+ const expressionById = new Map(expressions.map(expression => [expression.id, expression]))
+ const referencedIds = new Set(
+ selectedAnimations.flatMap(animation => animation.steps.map(step => step.expressionId))
+ )
+ const exportedExpressions = Object.fromEntries(
+ [...referencedIds].flatMap(expressionId => {
+ const expression = expressionById.get(expressionId)
+ return expression
+ ? [[expressionId, applyAvatarEyeDefaults(expression, avatar.eyes)] as const]
+ : []
+ })
+ )
+ const usedKeys = new Set()
+ const animations = Object.fromEntries(
+ selectedAnimations.map(animation => {
+ const key = animationKey(animation, usedKeys)
+ return [
+ key,
+ {
+ name: animation.name,
+ description: animation.description,
+ playbackMode: animation.playbackMode,
+ blink: { ...animation.blink },
+ steps: animation.steps
+ .filter(step => exportedExpressions[step.expressionId])
+ .map(({ expressionId, holdMs, transitionMs, transition }) => ({
+ expressionId,
+ holdMs,
+ transitionMs,
+ transition,
+ })),
+ } satisfies AvatarExportAnimation,
+ ]
+ })
+ )
+ return {
+ version: 1,
+ avatar: {
+ name: avatar.name,
+ surface: avatar.body.primary,
+ bodyNodes: avatar.body.nodes,
+ colors: avatar.colors,
+ },
+ expressions: exportedExpressions,
+ animations,
+ }
+}
+
+const serializedPayload = (payload: AvatarExportPayload) =>
+ JSON.stringify(payload)
+ .replace(/
+ value.replace(/[&<>"']/g, character => {
+ const entities: Record = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ }
+ return entities[character]
+ })
+
+const localizedPlaygroundCopy = (language: StudioLanguage) => {
+ const t = (text: string) => translateStudioText(text, language)
+ return {
+ title: t('Playground de l’avatar'),
+ eyebrow: t('Playground de l’avatar'),
+ playing: t('En lecture'),
+ paused: t('En pause'),
+ stopped: t('Arrêté'),
+ previewLabel: t('Aperçu procédural en direct'),
+ previewAriaLabel: t('Aperçu de l’avatar'),
+ animations: t('Animations'),
+ animationsHelp: t('Choisissez une animation exportée pour la lancer.'),
+ playback: t('Lecture'),
+ playbackHelp: t('Contrôlez l’animation active.'),
+ play: t('Lire'),
+ pause: t('Pause'),
+ stop: t('Arrêter'),
+ api: t('API JavaScript'),
+ apiHelp: t('Le module réutilisable est disponible dans avatar.js.'),
+ apiNote: t(
+ 'Ouvrez cette démo directement, ou servez le dossier localement pour importer avatar.js depuis un autre module.'
+ ),
+ }
+}
+
+export const generateJavaScriptAvatarModule = (
+ payload: AvatarExportPayload
+) => `${standaloneEngineSource}
+${`const DATA = ${serializedPayload(payload)};`}
+${proceduralBrowserRuntime}
+export const availableAnimations = Object.freeze(Object.keys(DATA.animations));
+export function createAvatar(target, options = {}) { return mountAvatar(target, options); }
+export default createAvatar;
+`
+
+export const generateJavaScriptAvatarHtml = (
+ payload: AvatarExportPayload,
+ language: StudioLanguage = 'en'
+) => {
+ const firstAnimation = Object.keys(payload.animations)[0]
+ const copy = localizedPlaygroundCopy(language)
+ return `
+
+
+
+
+ ${escapedHtml(payload.avatar.name)} · ${copy.title}
+
+
+
+
+
+
+
+
+
${copy.eyebrow}
+
${escapedHtml(payload.avatar.name)}
+
+
+
+
+ ${copy.playing}
+
+
+
+
+
+
+
+
+
+
+
${copy.animations}
+
${copy.animationsHelp}
+
+
+
+
+
+
+
+
+
+
${copy.playback}
+
${copy.playbackHelp}
+
+
+
+ ▶ ${copy.play}
+ Ⅱ ${copy.pause}
+ ■ ${copy.stop}
+
+
+
+
+
+
+
${copy.api}
+
${copy.apiHelp}
+
+
+ import { createAvatar } from './avatar.js'
+
+const avatar = createAvatar('#avatar', {
+ animation: ${JSON.stringify(firstAnimation)},
+ size: 320,
+})
+
+avatar.play(${JSON.stringify(firstAnimation)})
+avatar.pause()
+avatar.stop()
+ ${copy.apiNote}
+
+
+
+
+
+
+
+`
+}
+
+export const generateJavaScriptAvatarPackage = (
+ payload: AvatarExportPayload,
+ language: StudioLanguage = 'en'
+) => {
+ return createStoredZip([
+ { name: 'index.html', content: generateJavaScriptAvatarHtml(payload, language) },
+ { name: 'avatar.js', content: generateJavaScriptAvatarModule(payload) },
+ ])
+}
+
+const avatarExportSlug = (name: string) =>
+ name
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '') || 'avatar'
+
+const avatarComponentName = (name: string) => {
+ const identifier = avatarExportSlug(name)
+ .split('-')
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
+ .join('')
+ return /^[A-Za-z_$]/.test(identifier) ? identifier : `Avatar${identifier}`
+}
+
+export const generateReactAvatarRuntime = () => `
+export type AvatarData = Readonly<{
+ version: number
+ avatar: Readonly<{ name: string } & Record>
+ expressions: Readonly>
+ animations: Readonly>
+}>
+
+export type RuntimeAvatar = {
+ play: (animation?: AnimationName) => RuntimeAvatar
+ pause: () => RuntimeAvatar
+ stop: () => RuntimeAvatar
+ destroy: () => void
+}
+
+export type AvatarRuntimeModule = {
+ createAvatar: (
+ target: HTMLElement,
+ options: {
+ animation: AnimationName
+ autoplay: boolean
+ loop?: boolean
+ size: string
+ onAnimationEnd: (animation: AnimationName) => void
+ }
+ ) => RuntimeAvatar
+}
+
+export const AVATAR_RUNTIME_VERSION = 1
+const ENGINE_SOURCE = ${JSON.stringify(standaloneEngineSource)}
+const BROWSER_RUNTIME_SOURCE = ${JSON.stringify(proceduralBrowserRuntime)}
+const runtimes = new WeakMap>()
+
+const serializeData = (data: AvatarData) =>
+ JSON.stringify(data)
+ .replace(/(
+ data: AvatarData
+): Promise> => {
+ if (data.version !== AVATAR_RUNTIME_VERSION) {
+ throw new Error(
+ 'Avatar data version ' + data.version + ' requires a compatible avatar-runtime.ts'
+ )
+ }
+ const cached = runtimes.get(data)
+ if (cached) return cached as Promise>
+ const source =
+ ENGINE_SOURCE +
+ '\\nconst DATA = ' +
+ serializeData(data) +
+ ';\\n' +
+ BROWSER_RUNTIME_SOURCE +
+ '\\nexport function createAvatar(target, options = {}) { return mountAvatar(target, options); }'
+ const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }))
+ const runtime = import(/* @vite-ignore */ url).then(module => {
+ URL.revokeObjectURL(url)
+ return module as AvatarRuntimeModule
+ })
+ runtimes.set(data, runtime)
+ return runtime
+}
+`
+
+export const generateReactAvatarData = (payload: AvatarExportPayload) => `
+import type { AvatarData } from './avatar-runtime'
+
+export const avatarData = ${serializedPayload(payload)} as const satisfies AvatarData
+export type AnimationName = keyof typeof avatarData.animations
+`
+
+export const generateReactAvatarComponent = (payload: AvatarExportPayload) => {
+ const animationNames = Object.keys(payload.animations)
+ const slug = avatarExportSlug(payload.avatar.name)
+ const componentName = avatarComponentName(payload.avatar.name)
+ return `
+import { forwardRef, useEffect, useImperativeHandle, useRef, type CSSProperties } from 'react'
+import { loadAvatarRuntime, type RuntimeAvatar } from './avatar-runtime'
+import { avatarData, type AnimationName } from './${slug}.avatar'
+
+export type { AnimationName } from './${slug}.avatar'
+export type AvatarHandle = {
+ play: (animation?: AnimationName) => void
+ pause: () => void
+ stop: () => void
+}
+export type AvatarProps = {
+ animation?: AnimationName
+ playing?: boolean
+ loop?: boolean
+ size?: number | string
+ className?: string
+ style?: CSSProperties
+ onAnimationEnd?: (animation: AnimationName) => void
+}
+
+export const ${componentName} = forwardRef(function ${componentName}(
+ {
+ animation = ${JSON.stringify(animationNames[0])},
+ playing = true,
+ loop,
+ size = 240,
+ className,
+ style,
+ onAnimationEnd,
+ },
+ ref
+) {
+ const host = useRef(null)
+ const controller = useRef | null>(null)
+ const animationRef = useRef(animation)
+ const playingRef = useRef(playing)
+ const onAnimationEndRef = useRef(onAnimationEnd)
+ animationRef.current = animation
+ playingRef.current = playing
+ onAnimationEndRef.current = onAnimationEnd
+
+ useEffect(() => {
+ if (!host.current) return
+ let disposed = false
+ let avatar: RuntimeAvatar | null = null
+ void loadAvatarRuntime(avatarData).then(runtime => {
+ if (disposed || !host.current) return
+ avatar = runtime.createAvatar(host.current, {
+ animation: animationRef.current,
+ autoplay: playingRef.current,
+ loop,
+ size: '100%',
+ onAnimationEnd: next => onAnimationEndRef.current?.(next),
+ })
+ controller.current = avatar
+ })
+ return () => {
+ disposed = true
+ avatar?.destroy()
+ controller.current = null
+ }
+ }, [loop])
+
+ useEffect(() => {
+ const avatar = controller.current
+ if (!avatar) return
+ if (playing) avatar.play(animation)
+ else avatar.pause()
+ }, [animation, playing])
+
+ useImperativeHandle(ref, () => ({
+ play(next = animation) { controller.current?.play(next) },
+ pause() { controller.current?.pause() },
+ stop() { controller.current?.stop() },
+ }), [animation])
+
+ const dimension = typeof size === 'number' ? size + 'px' : size
+ return
+})
+
+export default ${componentName}
+`
+}
+
+export const generateReactAvatarPackage = (payload: AvatarExportPayload) => {
+ const slug = avatarExportSlug(payload.avatar.name)
+ const componentName = avatarComponentName(payload.avatar.name)
+ const avatarEntry = `export { default, ${componentName} } from './${componentName}'\nexport type { AnimationName, AvatarHandle, AvatarProps } from './${componentName}'\n`
+ return createStoredZip([
+ { name: 'avatar-runtime.ts', content: generateReactAvatarRuntime() },
+ { name: `${slug}.avatar.ts`, content: generateReactAvatarData(payload) },
+ { name: `${componentName}.tsx`, content: generateReactAvatarComponent(payload) },
+ { name: `${slug}.index.ts`, content: avatarEntry },
+ ])
+}
+
+export const avatarExportFileName = (name: string, extension: 'js' | 'tsx' | 'zip') => {
+ return `${avatarExportSlug(name)}-avatar.${extension}`
+}
diff --git a/src/expressionEditing.ts b/src/expressionEditing.ts
new file mode 100644
index 0000000..c3dad76
--- /dev/null
+++ b/src/expressionEditing.ts
@@ -0,0 +1,60 @@
+import type { Expression } from './geometry'
+
+export type EyeSide = 'Left' | 'Right'
+export type EyeLinks = { width: boolean; height: boolean; size: boolean; position?: boolean }
+
+const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
+const opposite = (side: EyeSide): EyeSide => (side === 'Left' ? 'Right' : 'Left')
+
+export const updateEyeDimension = (
+ expression: Expression,
+ side: EyeSide,
+ dimension: 'width' | 'height',
+ value: number,
+ linked: boolean
+) => {
+ const field = `${dimension}${side}` as 'widthLeft' | 'widthRight' | 'heightLeft' | 'heightRight'
+ const otherField = `${dimension}${opposite(side)}` as typeof field
+ const nextValue = clamp(value, 10, 100)
+ return {
+ ...expression,
+ [field]: nextValue,
+ ...(linked ? { [otherField]: nextValue } : {}),
+ }
+}
+
+export const scaleEye = (
+ expression: Expression,
+ side: EyeSide,
+ targetSize: number,
+ linked: boolean
+) => {
+ const widthField = `width${side}` as 'widthLeft' | 'widthRight'
+ const heightField = `height${side}` as 'heightLeft' | 'heightRight'
+ const factor = targetSize / Math.max(expression[widthField], expression[heightField], 1)
+ const next: Expression = {
+ ...expression,
+ [widthField]: clamp(expression[widthField] * factor, 10, 110),
+ [heightField]: clamp(expression[heightField] * factor, 10, 110),
+ }
+ if (!linked) return next
+ const otherSide = opposite(side)
+ const otherWidth = `width${otherSide}` as 'widthLeft' | 'widthRight'
+ const otherHeight = `height${otherSide}` as 'heightLeft' | 'heightRight'
+ next[otherWidth] = clamp(expression[otherWidth] * factor, 10, 110)
+ next[otherHeight] = clamp(expression[otherHeight] * factor, 10, 110)
+ return next
+}
+
+export const updateEyePosition = (
+ expression: Expression,
+ side: EyeSide,
+ axis: 'X' | 'Y',
+ value: number,
+ linked: boolean
+) => {
+ const field = `position${axis}${side}` as
+ 'positionXLeft' | 'positionXRight' | 'positionYLeft' | 'positionYRight'
+ const otherField = `position${axis}${opposite(side)}` as typeof field
+ return { ...expression, [field]: value, ...(linked ? { [otherField]: value } : {}) }
+}
diff --git a/src/geometry.ts b/src/geometry.ts
new file mode 100644
index 0000000..f2cf98c
--- /dev/null
+++ b/src/geometry.ts
@@ -0,0 +1,1251 @@
+import {
+ cursorLayout,
+ surfaceFrontSampleAt,
+ surfacePointAt,
+ surfaceSampleAt,
+ type SurfaceConfig,
+} from './surfaces'
+import type { BodyNode } from './body'
+
+export type Quaternion = readonly [number, number, number, number]
+export type Point3 = readonly [number, number, number]
+export type EyeMotion = 'none' | 'microSaccades' | 'shake'
+export type BodyMotion = 'none' | 'slowDrift' | 'shake'
+
+export type Expression = {
+ id: string
+ headX: number
+ headY: number
+ headZ: number
+ widthLeft: number
+ widthRight: number
+ heightLeft: number
+ heightRight: number
+ spacing: number
+ positionXLeft: number
+ positionXRight: number
+ positionYLeft: number
+ positionYRight: number
+ leftAngle: number
+ rightAngle: number
+ perspective: number
+ eyeMotion: EyeMotion
+ bodyMotion: BodyMotion
+ bodyColor?: string
+ eyeColor?: string
+}
+
+export type ExpressionNumericField = Exclude<
+ keyof Expression,
+ 'id' | 'bodyColor' | 'eyeColor' | 'eyeMotion' | 'bodyMotion'
+>
+
+export type AvatarPose = {
+ expression: Expression
+ orientation: Quaternion
+}
+
+export type AvatarGeometry = {
+ backPaths: string[]
+ frontPaths: string[]
+ backNodeIds: (string | null)[]
+ frontNodeIds: (string | null)[]
+ headPath: string
+ leftPath: string
+ rightPath: string
+ leftVisible: boolean
+ rightVisible: boolean
+ wirePaths: string[]
+}
+
+export type RenderAvatarOptions = {
+ includeWire?: boolean
+ bodyNodes?: BodyNode[]
+}
+
+export type EyeEditorGeometry = {
+ visible: boolean
+ selectionPath: string
+ widthGuide: string
+ heightGuide: string
+ rotationGuide: string
+ spacingGuide: string
+ center: Point3
+ widthHandle: Point3
+ heightHandle: Point3
+ rotateHandle: Point3
+ sizeHandle: Point3
+ spacingHandle: Point3
+}
+
+export type BodyNodeEditorGeometry = {
+ center: Point3
+ axes: Record<'x' | 'y' | 'z', Point3>
+ rings: Record<'x' | 'y' | 'z', Point3[]>
+}
+
+export const RADIUS = 120
+const FOCAL_LENGTH = 620
+const QUARTER_ARC_SAMPLES = 14
+
+export const expressionFields: ExpressionNumericField[] = [
+ 'headX',
+ 'headY',
+ 'headZ',
+ 'widthLeft',
+ 'widthRight',
+ 'heightLeft',
+ 'heightRight',
+ 'spacing',
+ 'positionXLeft',
+ 'positionXRight',
+ 'positionYLeft',
+ 'positionYRight',
+ 'leftAngle',
+ 'rightAngle',
+ 'perspective',
+]
+
+export const clamp = (value: number, min: number, max: number) =>
+ Math.max(min, Math.min(max, value))
+
+export const radians = (degrees: number) => (degrees * Math.PI) / 180
+
+export const normalizeQuaternion = ([w, x, y, z]: Quaternion): Quaternion => {
+ const length = Math.hypot(w, x, y, z) || 1
+ return [w / length, x / length, y / length, z / length]
+}
+
+export const multiplyQuaternions = (
+ [aw, ax, ay, az]: Quaternion,
+ [bw, bx, by, bz]: Quaternion
+): Quaternion =>
+ normalizeQuaternion([
+ aw * bw - ax * bx - ay * by - az * bz,
+ aw * bx + ax * bw + ay * bz - az * by,
+ aw * by - ax * bz + ay * bw + az * bx,
+ aw * bz + ax * by - ay * bx + az * bw,
+ ])
+
+export const quaternionFromAxisAngle = ([x, y, z]: Point3, angle: number): Quaternion => {
+ const halfAngle = angle / 2
+ const sine = Math.sin(halfAngle)
+ return normalizeQuaternion([Math.cos(halfAngle), x * sine, y * sine, z * sine])
+}
+
+export const quaternionFromEuler = (x: number, y: number, z: number): Quaternion => {
+ const xRotation = quaternionFromAxisAngle([1, 0, 0], x)
+ const yRotation = quaternionFromAxisAngle([0, 1, 0], y)
+ const zRotation = quaternionFromAxisAngle([0, 0, 1], z)
+ return multiplyQuaternions(multiplyQuaternions(zRotation, xRotation), yRotation)
+}
+
+export const quaternionFromVectors = (from: Point3, to: Point3): Quaternion => {
+ const dot = from[0] * to[0] + from[1] * to[1] + from[2] * to[2]
+ const cross: Point3 = [
+ from[1] * to[2] - from[2] * to[1],
+ from[2] * to[0] - from[0] * to[2],
+ from[0] * to[1] - from[1] * to[0],
+ ]
+ return normalizeQuaternion([1 + dot, cross[0], cross[1], cross[2]])
+}
+
+export const quaternionToEuler = ([w, x, y, z]: Quaternion): Point3 => {
+ const matrix00 = 1 - 2 * (y * y + z * z)
+ const matrix01 = 2 * (x * y - z * w)
+ const matrix10 = 2 * (x * y + z * w)
+ const matrix11 = 1 - 2 * (x * x + z * z)
+ const matrix20 = 2 * (x * z - y * w)
+ const matrix21 = 2 * (y * z + x * w)
+ const matrix22 = 1 - 2 * (x * x + y * y)
+ const headX = Math.asin(clamp(matrix21, -1, 1))
+ if (Math.abs(Math.cos(headX)) < 0.00001) return [headX, 0, Math.atan2(matrix10, matrix00)]
+ return [headX, Math.atan2(-matrix20, matrix22), Math.atan2(-matrix01, matrix11)]
+}
+
+const nearestEquivalentAngle = (angle: number, current: number) => {
+ let result = angle
+ while (result - current > 180) result -= 360
+ while (result - current < -180) result += 360
+ return clamp(result, -365, 365)
+}
+
+export const expressionWithOrientation = (
+ expression: Expression,
+ orientation: Quaternion
+): Expression => {
+ const [radiansX, radiansY, radiansZ] = quaternionToEuler(orientation)
+ const x = (radiansX * 180) / Math.PI
+ const y = (radiansY * 180) / Math.PI
+ const z = (radiansZ * 180) / Math.PI
+ return {
+ ...expression,
+ headX: nearestEquivalentAngle(x, expression.headX),
+ headY: nearestEquivalentAngle(y, expression.headY),
+ headZ: nearestEquivalentAngle(z, expression.headZ),
+ }
+}
+
+export const slerpQuaternion = (
+ start: Quaternion,
+ end: Quaternion,
+ progress: number
+): Quaternion => {
+ let target = end
+ let dot = start.reduce((total, value, index) => total + value * target[index], 0)
+ if (dot < 0) {
+ target = target.map(value => -value) as unknown as Quaternion
+ dot = -dot
+ }
+ if (dot > 0.9995) {
+ return normalizeQuaternion(
+ start.map(
+ (value, index) => value + (target[index] - value) * progress
+ ) as unknown as Quaternion
+ )
+ }
+ const angle = Math.acos(clamp(dot, -1, 1))
+ const sine = Math.sin(angle)
+ const startWeight = Math.sin((1 - progress) * angle) / sine
+ const targetWeight = Math.sin(progress * angle) / sine
+ return normalizeQuaternion(
+ start.map(
+ (value, index) => value * startWeight + target[index] * targetWeight
+ ) as unknown as Quaternion
+ )
+}
+
+export const rotateWithQuaternion = ([w, x, y, z]: Quaternion, [px, py, pz]: Point3): Point3 => {
+ const tx = 2 * (y * pz - z * py)
+ const ty = 2 * (z * px - x * pz)
+ const tz = 2 * (x * py - y * px)
+ return [
+ px + w * tx + (y * tz - z * ty),
+ py + w * ty + (z * tx - x * tz),
+ pz + w * tz + (x * ty - y * tx),
+ ]
+}
+
+const roundedRectangle = (width: number, height: number): (readonly [number, number])[] => {
+ const halfWidth = width / 2
+ const halfHeight = height / 2
+ const cornerRadius = Math.min(halfHeight, halfWidth)
+ const points: (readonly [number, number])[] = []
+ const addLine = (start: readonly [number, number], end: readonly [number, number]) => {
+ const samples = Math.max(2, Math.ceil(Math.hypot(end[0] - start[0], end[1] - start[1]) / 1.5))
+ for (let index = 0; index < samples; index += 1) {
+ const progress = index / samples
+ points.push([
+ start[0] + (end[0] - start[0]) * progress,
+ start[1] + (end[1] - start[1]) * progress,
+ ])
+ }
+ }
+ const addArc = (centerX: number, centerY: number, startAngle: number) => {
+ for (let index = 0; index < QUARTER_ARC_SAMPLES; index += 1) {
+ const angle = startAngle + (index / QUARTER_ARC_SAMPLES) * (Math.PI / 2)
+ points.push([
+ centerX + Math.cos(angle) * cornerRadius,
+ centerY + Math.sin(angle) * cornerRadius,
+ ])
+ }
+ }
+ addLine([-halfWidth + cornerRadius, -halfHeight], [halfWidth - cornerRadius, -halfHeight])
+ addArc(halfWidth - cornerRadius, -halfHeight + cornerRadius, -Math.PI / 2)
+ addLine([halfWidth, -halfHeight + cornerRadius], [halfWidth, halfHeight - cornerRadius])
+ addArc(halfWidth - cornerRadius, halfHeight - cornerRadius, 0)
+ addLine([halfWidth - cornerRadius, halfHeight], [-halfWidth + cornerRadius, halfHeight])
+ addArc(-halfWidth + cornerRadius, halfHeight - cornerRadius, Math.PI / 2)
+ addLine([-halfWidth, halfHeight - cornerRadius], [-halfWidth, -halfHeight + cornerRadius])
+ addArc(-halfWidth + cornerRadius, -halfHeight + cornerRadius, Math.PI)
+ return points
+}
+
+const project = (point: Point3, perspective: number): Point3 => {
+ const denominator = FOCAL_LENGTH - point[2] * perspective
+ const scale = Math.abs(denominator) < 0.0001 ? FOCAL_LENGTH / 0.0001 : FOCAL_LENGTH / denominator
+ return [point[0] * scale, point[1] * scale, point[2]]
+}
+
+export const axisVector = (axis: 'x' | 'y' | 'z'): Point3 =>
+ axis === 'x' ? [1, 0, 0] : axis === 'y' ? [0, 1, 0] : [0, 0, 1]
+
+export const rotateExpressionAroundAxis = (
+ expression: Expression,
+ axis: 'x' | 'y' | 'z',
+ deltaDegrees: number
+) => {
+ const startOrientation = poseFromExpression(expression).orientation
+ const worldAxis = rotateWithQuaternion(startOrientation, axisVector(axis))
+ const orientation = multiplyQuaternions(
+ quaternionFromAxisAngle(worldAxis, radians(deltaDegrees)),
+ startOrientation
+ )
+ return expressionWithOrientation(expression, orientation)
+}
+
+export const rotateExpressionAroundCamera = (expression: Expression, deltaRadians: number) => {
+ const startOrientation = poseFromExpression(expression).orientation
+ return expressionWithOrientation(
+ expression,
+ multiplyQuaternions(quaternionFromAxisAngle([0, 0, 1], deltaRadians), startOrientation)
+ )
+}
+
+const arcballVector = ([xValue, yValue]: readonly [number, number]): Point3 => {
+ const x = xValue / RADIUS
+ const y = yValue / RADIUS
+ const squaredLength = x * x + y * y
+ if (squaredLength <= 1) return [x, y, Math.sqrt(1 - squaredLength)]
+ const length = Math.sqrt(squaredLength)
+ return [x / length, y / length, 0]
+}
+
+export const rotateExpressionWithArcball = (
+ expression: Expression,
+ startPoint: readonly [number, number],
+ currentPoint: readonly [number, number]
+) => {
+ const startOrientation = poseFromExpression(expression).orientation
+ const delta = quaternionFromVectors(arcballVector(startPoint), arcballVector(currentPoint))
+ return expressionWithOrientation(expression, multiplyQuaternions(delta, startOrientation))
+}
+
+export const rotationRing = (pose: AvatarPose, axis: 'x' | 'y' | 'z', radius = 30): Point3[] =>
+ Array.from({ length: 97 }, (_, index) => {
+ const angle = (index / 96) * Math.PI * 2
+ const cosine = Math.cos(angle)
+ const sine = Math.sin(angle)
+ const point: Point3 =
+ axis === 'x' ? [0, cosine, sine] : axis === 'y' ? [cosine, 0, sine] : [cosine, sine, 0]
+ const rotated = rotateWithQuaternion(pose.orientation, point)
+ return [rotated[0] * radius, rotated[1] * radius, rotated[2]]
+ })
+
+export const renderBodyNodeEditor = (
+ pose: AvatarPose,
+ node: BodyNode,
+ axisLength = 34,
+ ringRadius = 26
+): BodyNodeEditorGeometry => {
+ const projectInHeadSpace = (point: Point3) =>
+ project(rotateWithQuaternion(pose.orientation, point), pose.expression.perspective)
+ const center = projectInHeadSpace(node.position)
+ const localOrientation = quaternionFromEuler(
+ radians(node.rotation[0]),
+ radians(node.rotation[1]),
+ radians(node.rotation[2])
+ )
+ const axes = Object.fromEntries(
+ (['x', 'y', 'z'] as const).map(axis => {
+ const vector = rotateWithQuaternion(localOrientation, axisVector(axis))
+ return [
+ axis,
+ projectInHeadSpace([
+ node.position[0] + vector[0] * axisLength,
+ node.position[1] + vector[1] * axisLength,
+ node.position[2] + vector[2] * axisLength,
+ ]),
+ ]
+ })
+ ) as BodyNodeEditorGeometry['axes']
+ ;(['x', 'y', 'z'] as const).forEach(axis => {
+ const endpoint = axes[axis]
+ if (Math.hypot(endpoint[0] - center[0], endpoint[1] - center[1]) >= 12) return
+ const fallback: Point3 =
+ axis === 'x'
+ ? [center[0] + 18, center[1], endpoint[2]]
+ : axis === 'y'
+ ? [center[0], center[1] + 18, endpoint[2]]
+ : [center[0] + 14, center[1] + 14, endpoint[2]]
+ axes[axis] = fallback
+ })
+ const rings = Object.fromEntries(
+ (['x', 'y', 'z'] as const).map(axis => [
+ axis,
+ Array.from({ length: 65 }, (_, index) => {
+ const angle = (index / 64) * Math.PI * 2
+ const cosine = Math.cos(angle) * ringRadius
+ const sine = Math.sin(angle) * ringRadius
+ const localPoint: Point3 =
+ axis === 'x' ? [0, cosine, sine] : axis === 'y' ? [cosine, 0, sine] : [cosine, sine, 0]
+ const rotated = rotateWithQuaternion(localOrientation, localPoint)
+ return projectInHeadSpace([
+ node.position[0] + rotated[0],
+ node.position[1] + rotated[1],
+ node.position[2] + rotated[2],
+ ])
+ }),
+ ])
+ ) as BodyNodeEditorGeometry['rings']
+ return { center, axes, rings }
+}
+
+export const translateBodyNodeAlongLocalAxis = (
+ node: BodyNode,
+ axis: 'x' | 'y' | 'z',
+ distance: number
+): BodyNode => {
+ const orientation = quaternionFromEuler(
+ radians(node.rotation[0]),
+ radians(node.rotation[1]),
+ radians(node.rotation[2])
+ )
+ const direction = rotateWithQuaternion(orientation, axisVector(axis))
+ return {
+ ...node,
+ position: [
+ node.position[0] + direction[0] * distance,
+ node.position[1] + direction[1] * distance,
+ node.position[2] + direction[2] * distance,
+ ],
+ }
+}
+
+export const translateBodyNodeInCameraPlane = (
+ node: BodyNode,
+ pose: AvatarPose,
+ screenDeltaX: number,
+ screenDeltaY: number
+): BodyNode => {
+ const cameraPosition = rotateWithQuaternion(pose.orientation, node.position)
+ const denominator = FOCAL_LENGTH - cameraPosition[2] * pose.expression.perspective
+ const perspectiveScale =
+ Math.abs(denominator) < 0.0001 ? FOCAL_LENGTH / 0.0001 : FOCAL_LENGTH / denominator
+ const [w, x, y, z] = pose.orientation
+ const headDelta = rotateWithQuaternion(
+ [w, -x, -y, -z],
+ [screenDeltaX / perspectiveScale, screenDeltaY / perspectiveScale, 0]
+ )
+ return {
+ ...node,
+ position: [
+ node.position[0] + headDelta[0],
+ node.position[1] + headDelta[1],
+ node.position[2] + headDelta[2],
+ ],
+ }
+}
+
+export const rotateBodyNodeAroundLocalAxis = (
+ node: BodyNode,
+ axis: 'x' | 'y' | 'z',
+ deltaDegrees: number
+): BodyNode => {
+ const orientation = quaternionFromEuler(
+ radians(node.rotation[0]),
+ radians(node.rotation[1]),
+ radians(node.rotation[2])
+ )
+ const rotated = multiplyQuaternions(
+ orientation,
+ quaternionFromAxisAngle(axisVector(axis), radians(deltaDegrees))
+ )
+ const next = quaternionToEuler(rotated).map(value => (value * 180) / Math.PI) as [
+ number,
+ number,
+ number,
+ ]
+ return {
+ ...node,
+ rotation: next.map((value, index) => nearestEquivalentAngle(value, node.rotation[index])) as [
+ number,
+ number,
+ number,
+ ],
+ }
+}
+
+const path = (points: Point3[], close = true) => {
+ if (!points.length) return ''
+ return `M${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}${points
+ .slice(1)
+ .map(point => `L${point[0].toFixed(2)} ${point[1].toFixed(2)}`)
+ .join('')}${close ? 'Z' : ''}`
+}
+
+export const poseFromExpression = (expression: Expression): AvatarPose => ({
+ expression,
+ orientation: quaternionFromEuler(
+ radians(expression.headX),
+ radians(expression.headY),
+ radians(expression.headZ)
+ ),
+})
+
+export const interpolatePose = (from: AvatarPose, to: AvatarPose, progress: number): AvatarPose => {
+ const expression: Expression = { ...from.expression }
+ expressionFields.forEach(field => {
+ expression[field] =
+ from.expression[field] + (to.expression[field] - from.expression[field]) * progress
+ })
+ return {
+ expression,
+ orientation: slerpQuaternion(from.orientation, to.orientation, progress),
+ }
+}
+
+type ProjectedSurfacePoint = { point: Point3; normal: Point3 }
+type LocalSurfacePoint = ProjectedSurfacePoint
+
+const MAX_SURFACE_CACHE_ENTRIES = 24
+const HEAD_LATITUDE_SAMPLES = 25
+const HEAD_LONGITUDE_SAMPLES = 73
+const PRIMITIVE_RING_SAMPLES = 144
+const ROUNDED_PRIMITIVE_LATITUDE_SAMPLES = 33
+const ROUNDED_PRIMITIVE_LONGITUDE_SAMPLES = 73
+const headSamplesCache = new Map()
+const accessorySamplesCache = new Map()
+const wireSamplesCache = new Map()
+
+const surfaceCacheKey = (surface: SurfaceConfig) =>
+ [
+ surface.type,
+ surface.width,
+ surface.height,
+ surface.depth,
+ surface.roundness,
+ surface.morphRoundness,
+ surface.tipRoundness,
+ surface.baseRoundness,
+ ]
+ .map(value => (typeof value === 'number' ? value.toFixed(4) : value))
+ .join(':')
+
+const cacheSurfaceValue = (cache: Map, key: string, value: Value) => {
+ if (cache.size >= MAX_SURFACE_CACHE_ENTRIES) cache.delete(cache.keys().next().value!)
+ cache.set(key, value)
+ return value
+}
+
+const localSurfacePoint = (
+ surface: SurfaceConfig,
+ longitude: number,
+ latitude: number
+): LocalSurfacePoint => surfaceSampleAt(surface, longitude, latitude)
+
+const projectLocalSurfacePoint = (
+ pose: AvatarPose,
+ sample: LocalSurfacePoint
+): ProjectedSurfacePoint => ({
+ point: project(rotateWithQuaternion(pose.orientation, sample.point), pose.expression.perspective),
+ normal: rotateWithQuaternion(pose.orientation, sample.normal),
+})
+
+const canonicalFaceCoordinates = (x: number, y: number): readonly [number, number] => {
+ const longitude = x / RADIUS
+ const latitude = y / RADIUS
+ return [RADIUS * Math.cos(latitude) * Math.sin(longitude), RADIUS * Math.sin(latitude)]
+}
+
+const projectFacePoint = (
+ pose: AvatarPose,
+ surface: SurfaceConfig,
+ x: number,
+ y: number
+): ProjectedSurfacePoint => {
+ const [faceX, faceY] = canonicalFaceCoordinates(x, y)
+ return projectLocalSurfacePoint(pose, surfaceFrontSampleAt(surface, faceX, faceY))
+}
+
+const eyePoints = (
+ pose: AvatarPose,
+ surface: SurfaceConfig,
+ side: -1 | 1,
+ blink: number
+): ProjectedSurfacePoint[] => {
+ const expression = pose.expression
+ const suffix = side < 0 ? 'Left' : 'Right'
+ const width = expression[`width${suffix}`]
+ const restingHeight = expression[`height${suffix}`]
+ const height = 5 + (restingHeight - 5) * blink
+ const centerX = (side * expression.spacing) / 2 + expression[`positionX${suffix}`]
+ const centerY = expression[`positionY${suffix}`]
+ const angle = radians(side < 0 ? expression.leftAngle : expression.rightAngle)
+ return roundedRectangle(width, height).map(([localX, localY]) => {
+ const rotatedX = localX * Math.cos(angle) - localY * Math.sin(angle)
+ const rotatedY = localX * Math.sin(angle) + localY * Math.cos(angle)
+ return projectFacePoint(pose, surface, centerX + rotatedX, centerY + rotatedY)
+ })
+}
+
+const visiblePath = (points: ProjectedSurfacePoint[]) => {
+ const segments: Point3[][] = []
+ let segment: Point3[] = []
+ points.forEach(({ point, normal }) => {
+ if (normal[2] > 0) segment.push(point)
+ else if (segment.length) {
+ segments.push(segment)
+ segment = []
+ }
+ })
+ if (segment.length) segments.push(segment)
+ return segments
+ .filter(item => item.length > 1)
+ .map(item => path(item, false))
+ .join('')
+}
+
+const wirePaths = (pose: AvatarPose, surface: SurfaceConfig): string[] => {
+ const key = surfaceCacheKey(surface)
+ let samples = wireSamplesCache.get(key)
+ if (!samples) {
+ const parallels = [-60, -30, 0, 30, 60].map(latitude =>
+ Array.from({ length: 73 }, (_, index) =>
+ localSurfacePoint(surface, radians(-180 + index * 5), radians(latitude))
+ )
+ )
+ const meridians = Array.from(
+ { length: 12 },
+ (_, longitudeIndex) => -150 + longitudeIndex * 30
+ ).map(longitude =>
+ Array.from({ length: 37 }, (_, index) =>
+ localSurfacePoint(surface, radians(longitude), radians(-90 + index * 5))
+ )
+ )
+ samples = cacheSurfaceValue(wireSamplesCache, key, [...parallels, ...meridians])
+ }
+ return samples.map(curve =>
+ visiblePath(curve.map(sample => projectLocalSurfacePoint(pose, sample)))
+ )
+}
+
+const projectEyePoint = (
+ pose: AvatarPose,
+ surface: SurfaceConfig,
+ side: -1 | 1,
+ localX: number,
+ localY: number
+): Point3 => {
+ const expression = pose.expression
+ const suffix = side < 0 ? 'Left' : 'Right'
+ const angle = radians(side < 0 ? expression.leftAngle : expression.rightAngle)
+ const rotatedX = localX * Math.cos(angle) - localY * Math.sin(angle)
+ const rotatedY = localX * Math.sin(angle) + localY * Math.cos(angle)
+ return projectFacePoint(
+ pose,
+ surface,
+ (side * expression.spacing) / 2 + expression[`positionX${suffix}`] + rotatedX,
+ expression[`positionY${suffix}`] + rotatedY
+ ).point
+}
+
+export const renderEyeEditor = (
+ pose: AvatarPose,
+ surface: SurfaceConfig,
+ side: -1 | 1
+): EyeEditorGeometry => {
+ const expression = pose.expression
+ const suffix = side < 0 ? 'Left' : 'Right'
+ const width = expression[`width${suffix}`]
+ const height = expression[`height${suffix}`]
+ const selectedSamples = eyePoints(pose, surface, side, 1)
+ const selectedPoints = selectedSamples.map(sample => sample.point)
+ const center = projectEyePoint(pose, surface, side, 0, 0)
+ const widthHandle = projectEyePoint(pose, surface, side, width / 2 + 9, 0)
+ const heightHandle = projectEyePoint(pose, surface, side, 0, -height / 2 - 9)
+ const rotateHandle = projectEyePoint(pose, surface, side, 0, -height / 2 - 30)
+ const sizeHandle = projectEyePoint(pose, surface, side, width / 2 + 11, height / 2 + 11)
+ const leftCenter = projectEyePoint(pose, surface, -1, 0, 0)
+ const rightCenter = projectEyePoint(pose, surface, 1, 0, 0)
+ const spacingCenterX = (expression.positionXLeft + expression.positionXRight) / 2
+ const spacingCenterY = (expression.positionYLeft + expression.positionYRight) / 2
+ const spacingHandle = projectFacePoint(
+ pose,
+ surface,
+ spacingCenterX,
+ spacingCenterY + height / 2 + 34
+ ).point
+ const spacingMiddle: Point3 = [
+ (leftCenter[0] + rightCenter[0]) / 2,
+ (leftCenter[1] + rightCenter[1]) / 2,
+ (leftCenter[2] + rightCenter[2]) / 2,
+ ]
+ const line = (from: Point3, to: Point3) => path([from, to], false)
+ return {
+ visible: selectedSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0,
+ selectionPath: path(selectedPoints),
+ widthGuide: line(center, widthHandle),
+ heightGuide: line(center, heightHandle),
+ rotationGuide: line(heightHandle, rotateHandle),
+ spacingGuide: `${line(leftCenter, rightCenter)}${line(spacingMiddle, spacingHandle)}`,
+ center,
+ widthHandle,
+ heightHandle,
+ rotateHandle,
+ sizeHandle,
+ spacingHandle,
+ }
+}
+
+const convexHull = (points: Point3[]): Point3[] => {
+ const sorted = [...points].sort((left, right) => left[0] - right[0] || left[1] - right[1])
+ const cross = (origin: Point3, first: Point3, second: Point3) =>
+ (first[0] - origin[0]) * (second[1] - origin[1]) -
+ (first[1] - origin[1]) * (second[0] - origin[0])
+ const half = (source: Point3[]) => {
+ const result: Point3[] = []
+ source.forEach(point => {
+ while (result.length >= 2 && cross(result.at(-2)!, result.at(-1)!, point) <= 0) result.pop()
+ result.push(point)
+ })
+ return result
+ }
+ return [...half(sorted).slice(0, -1), ...half(sorted.reverse()).slice(0, -1)]
+}
+
+const smoothClosedPath = (points: Point3[]) => {
+ if (points.length < 3) return path(points)
+ const pointAt = (index: number) => points[(index + points.length) % points.length]
+ return `M${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}${points
+ .map((point, index) => {
+ const previous = pointAt(index - 1)
+ const next = pointAt(index + 1)
+ const afterNext = pointAt(index + 2)
+ const firstControl: Point3 = [
+ point[0] + (next[0] - previous[0]) / 6,
+ point[1] + (next[1] - previous[1]) / 6,
+ point[2],
+ ]
+ const secondControl: Point3 = [
+ next[0] - (afterNext[0] - point[0]) / 6,
+ next[1] - (afterNext[1] - point[1]) / 6,
+ next[2],
+ ]
+ return `C${firstControl[0].toFixed(2)} ${firstControl[1].toFixed(2)} ${secondControl[0].toFixed(2)} ${secondControl[1].toFixed(2)} ${next[0].toFixed(2)} ${next[1].toFixed(2)}`
+ })
+ .join('')}Z`
+}
+
+const densifyClosedPoints = (points: Point3[], maximumDistance = 7) =>
+ points.flatMap((point, index) => {
+ const next = points[(index + 1) % points.length]
+ const steps = Math.max(
+ 1,
+ Math.ceil(Math.hypot(next[0] - point[0], next[1] - point[1]) / maximumDistance)
+ )
+ return Array.from({ length: steps }, (_, step) => {
+ const progress = step / steps
+ return [
+ point[0] + (next[0] - point[0]) * progress,
+ point[1] + (next[1] - point[1]) * progress,
+ point[2] + (next[2] - point[2]) * progress,
+ ] as Point3
+ })
+ })
+
+const smoothOpenPath = (points: Point3[]) => {
+ if (!points.length) return ''
+ if (points.length === 1) return `${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}`
+ return points
+ .slice(0, -1)
+ .map((point, index) => {
+ const previous = points[Math.max(0, index - 1)]
+ const next = points[index + 1]
+ const afterNext = points[Math.min(points.length - 1, index + 2)]
+ const firstControlX = point[0] + (next[0] - previous[0]) / 6
+ const firstControlY = point[1] + (next[1] - previous[1]) / 6
+ const secondControlX = next[0] - (afterNext[0] - point[0]) / 6
+ const secondControlY = next[1] - (afterNext[1] - point[1]) / 6
+ return `C${firstControlX.toFixed(2)} ${firstControlY.toFixed(2)} ${secondControlX.toFixed(2)} ${secondControlY.toFixed(2)} ${next[0].toFixed(2)} ${next[1].toFixed(2)}`
+ })
+ .join('')
+}
+
+const projectLocalPoint = (pose: AvatarPose, point: Point3) =>
+ project(rotateWithQuaternion(pose.orientation, point), pose.expression.perspective)
+
+const ringPoints = (width: number, depth: number, y: number) =>
+ Array.from({ length: PRIMITIVE_RING_SAMPLES + 1 }, (_, index) => {
+ const angle = (index / PRIMITIVE_RING_SAMPLES) * Math.PI * 2
+ return [(width / 2) * Math.sin(angle), y, (depth / 2) * Math.cos(angle)] as Point3
+ })
+
+const projectedRoundedPrimitivePath = (pose: AvatarPose, surface: SurfaceConfig) => {
+ const key = surfaceCacheKey(surface)
+ let localSamples = headSamplesCache.get(key)
+ if (!localSamples) {
+ localSamples = Array.from(
+ { length: ROUNDED_PRIMITIVE_LATITUDE_SAMPLES },
+ (_, latitudeIndex) => {
+ const latitude =
+ -Math.PI / 2 + (latitudeIndex / (ROUNDED_PRIMITIVE_LATITUDE_SAMPLES - 1)) * Math.PI
+ return Array.from({ length: ROUNDED_PRIMITIVE_LONGITUDE_SAMPLES }, (_, longitudeIndex) => {
+ const longitude =
+ -Math.PI + (longitudeIndex / (ROUNDED_PRIMITIVE_LONGITUDE_SAMPLES - 1)) * Math.PI * 2
+ return surfacePointAt(surface, longitude, latitude)
+ })
+ }
+ ).flat()
+ cacheSurfaceValue(headSamplesCache, key, localSamples)
+ }
+ const projected = localSamples.map(point => projectLocalPoint(pose, point))
+ return smoothClosedPath(densifyClosedPoints(convexHull(projected)))
+}
+
+const projectedCylinderPath = (pose: AvatarPose, surface: SurfaceConfig) => {
+ if (surface.roundness > 0 || (surface.morphRoundness ?? 0) > 0) {
+ return projectedRoundedPrimitivePath(pose, surface)
+ }
+
+ const halfHeight = surface.height / 2
+ const projected = [
+ ...ringPoints(surface.width, surface.depth, -halfHeight),
+ ...ringPoints(surface.width, surface.depth, halfHeight),
+ ].map(point => projectLocalPoint(pose, point))
+ return smoothClosedPath(densifyClosedPoints(convexHull(projected)))
+}
+
+const projectedCursorBodyPath = (pose: AvatarPose, surface: SurfaceConfig) => {
+ const layout = cursorLayout(surface)
+ const halfHeight = layout.bodyHeight / 2
+ const projected = [
+ ...ringPoints(layout.bodyWidth, layout.bodyDepth, layout.bodyCenterY - halfHeight),
+ ...ringPoints(layout.bodyWidth, layout.bodyDepth, layout.bodyCenterY + halfHeight),
+ ].map(point => projectLocalPoint(pose, point))
+ return smoothClosedPath(densifyClosedPoints(convexHull(projected)))
+}
+
+const projectedCursorConePath = (pose: AvatarPose, surface: SurfaceConfig) => {
+ const layout = cursorLayout(surface)
+ const apex = projectLocalPoint(pose, [0, layout.coneApexY, 0])
+ const base = ringPoints(surface.width, surface.depth, layout.coneBaseY).map(point =>
+ projectLocalPoint(pose, point)
+ )
+ return smoothClosedPath(densifyClosedPoints(convexHull([...base, apex])))
+}
+
+const projectedConePath = (pose: AvatarPose, surface: SurfaceConfig) => {
+ if (
+ (surface.morphRoundness ?? 0) > 0 ||
+ (surface.tipRoundness ?? 0) > 0 ||
+ (surface.baseRoundness ?? 0) > 0
+ ) {
+ return projectedRoundedPrimitivePath(pose, surface)
+ }
+
+ const apex = projectLocalPoint(pose, [0, -surface.height / 2, 0])
+ const base = ringPoints(surface.width, surface.depth, surface.height / 2).map(point =>
+ projectLocalPoint(pose, point)
+ )
+ const hull = convexHull([...base, apex])
+ const apexIndex = hull.findIndex(
+ point => Math.hypot(point[0] - apex[0], point[1] - apex[1]) < 0.01
+ )
+ if (apexIndex < 0) return smoothClosedPath(hull)
+
+ const ordered = [...hull.slice(apexIndex), ...hull.slice(0, apexIndex)]
+ const baseArc = ordered.slice(1)
+ if (baseArc.length < 2) return path(hull)
+ return `M${apex[0].toFixed(2)} ${apex[1].toFixed(2)}L${baseArc[0][0].toFixed(2)} ${baseArc[0][1].toFixed(2)}${smoothOpenPath(baseArc)}L${apex[0].toFixed(2)} ${apex[1].toFixed(2)}Z`
+}
+
+const projectedCubePath = (pose: AvatarPose, surface: SurfaceConfig) => {
+ if (surface.roundness > 0) return projectedRoundedPrimitivePath(pose, surface)
+
+ const halfWidth = surface.width / 2
+ const halfHeight = surface.height / 2
+ const halfDepth = surface.depth / 2
+ const vertices = [-1, 1].flatMap(x =>
+ [-1, 1].flatMap(y => [-1, 1].map(z => [x * halfWidth, y * halfHeight, z * halfDepth] as Point3))
+ )
+ return path(convexHull(vertices.map(point => projectLocalPoint(pose, point))))
+}
+
+const projectedDiamondPath = (pose: AvatarPose, surface: SurfaceConfig) => {
+ if (surface.roundness > 0) return projectedRoundedPrimitivePath(pose, surface)
+
+ const halfWidth = surface.width / 2
+ const halfHeight = surface.height / 2
+ const halfDepth = surface.depth / 2
+ const vertices: Point3[] = [
+ [-halfWidth, 0, 0],
+ [halfWidth, 0, 0],
+ [0, -halfHeight, 0],
+ [0, halfHeight, 0],
+ [0, 0, -halfDepth],
+ [0, 0, halfDepth],
+ ]
+ return path(convexHull(vertices.map(point => projectLocalPoint(pose, point))))
+}
+
+type ProjectedEllipse = {
+ centerX: number
+ centerY: number
+ majorRadius: number
+ minorRadius: number
+ rotation: number
+}
+
+const ellipseProjection = (
+ centerX: number,
+ centerY: number,
+ covarianceXX: number,
+ covarianceXY: number,
+ covarianceYY: number
+): ProjectedEllipse | null => {
+ const trace = covarianceXX + covarianceYY
+ const difference = Math.hypot(covarianceXX - covarianceYY, covarianceXY * 2)
+ const majorSquared = (trace + difference) / 2
+ const minorSquared = (trace - difference) / 2
+ if (majorSquared <= 0 || minorSquared <= 0) return null
+
+ return {
+ centerX,
+ centerY,
+ majorRadius: Math.sqrt(majorSquared),
+ minorRadius: Math.sqrt(minorSquared),
+ rotation: Math.atan2(covarianceXY * 2, covarianceXX - covarianceYY) / 2,
+ }
+}
+
+const ellipsePath = ({
+ centerX,
+ centerY,
+ majorRadius,
+ minorRadius,
+ rotation,
+}: ProjectedEllipse) => {
+ const rotationDegrees = (rotation * 180) / Math.PI
+ const offsetX = Math.cos(rotation) * majorRadius
+ const offsetY = Math.sin(rotation) * majorRadius
+ const startX = centerX + offsetX
+ const startY = centerY + offsetY
+ const endX = centerX - offsetX
+ const endY = centerY - offsetY
+
+ return `M${startX.toFixed(2)} ${startY.toFixed(2)}A${majorRadius.toFixed(2)} ${minorRadius.toFixed(2)} ${rotationDegrees.toFixed(2)} 0 1 ${endX.toFixed(2)} ${endY.toFixed(2)}A${majorRadius.toFixed(2)} ${minorRadius.toFixed(2)} ${rotationDegrees.toFixed(2)} 0 1 ${startX.toFixed(2)} ${startY.toFixed(2)}Z`
+}
+
+const projectedEllipsoid = (
+ pose: AvatarPose,
+ axes: Point3,
+ localCenter: Point3 = [0, 0, 0]
+): ProjectedEllipse | null => {
+ const rotatedAxes = [
+ rotateWithQuaternion(pose.orientation, [1, 0, 0]),
+ rotateWithQuaternion(pose.orientation, [0, 1, 0]),
+ rotateWithQuaternion(pose.orientation, [0, 0, 1]),
+ ]
+ const center = rotateWithQuaternion(pose.orientation, localCenter)
+
+ if (Math.abs(pose.expression.perspective) < 0.0001) {
+ const covarianceXX = rotatedAxes.reduce(
+ (total, axis, index) => total + axis[0] * axis[0] * axes[index] * axes[index],
+ 0
+ )
+ const covarianceXY = rotatedAxes.reduce(
+ (total, axis, index) => total + axis[0] * axis[1] * axes[index] * axes[index],
+ 0
+ )
+ const covarianceYY = rotatedAxes.reduce(
+ (total, axis, index) => total + axis[1] * axis[1] * axes[index] * axes[index],
+ 0
+ )
+ return ellipseProjection(center[0], center[1], covarianceXX, covarianceXY, covarianceYY)
+ }
+
+ const inverseAxesSquared = axes.map(axis => 1 / (axis * axis))
+ const quadratic = Array.from({ length: 3 }, (_, row) =>
+ Array.from({ length: 3 }, (_, column) =>
+ rotatedAxes.reduce(
+ (total, axis, index) => total + axis[row] * inverseAxesSquared[index] * axis[column],
+ 0
+ )
+ )
+ )
+ const focalLength = FOCAL_LENGTH / pose.expression.perspective
+ const cameraOffset: Point3 = [-center[0], -center[1], focalLength - center[2]]
+ const cameraNormal: Point3 = [
+ quadratic[0][0] * cameraOffset[0] +
+ quadratic[0][1] * cameraOffset[1] +
+ quadratic[0][2] * cameraOffset[2],
+ quadratic[1][0] * cameraOffset[0] +
+ quadratic[1][1] * cameraOffset[1] +
+ quadratic[1][2] * cameraOffset[2],
+ quadratic[2][0] * cameraOffset[0] +
+ quadratic[2][1] * cameraOffset[1] +
+ quadratic[2][2] * cameraOffset[2],
+ ]
+ const cameraTerm =
+ cameraOffset[0] * cameraNormal[0] +
+ cameraOffset[1] * cameraNormal[1] +
+ cameraOffset[2] * cameraNormal[2] -
+ 1
+ const tangentLinear = [cameraNormal[0], cameraNormal[1], -focalLength * cameraNormal[2]]
+ const rayQuadratic = [
+ [quadratic[0][0], quadratic[0][1], -focalLength * quadratic[0][2]],
+ [quadratic[1][0], quadratic[1][1], -focalLength * quadratic[1][2]],
+ [
+ -focalLength * quadratic[2][0],
+ -focalLength * quadratic[2][1],
+ focalLength * focalLength * quadratic[2][2],
+ ],
+ ]
+ const conic = Array.from({ length: 3 }, (_, row) =>
+ Array.from(
+ { length: 3 },
+ (_, column) =>
+ tangentLinear[row] * tangentLinear[column] - cameraTerm * rayQuadratic[row][column]
+ )
+ )
+ const determinant = conic[0][0] * conic[1][1] - conic[0][1] * conic[0][1]
+ if (Math.abs(determinant) < 1e-12) return null
+
+ const centerX = -(conic[1][1] * conic[0][2] - conic[0][1] * conic[1][2]) / determinant
+ const centerY = (conic[0][1] * conic[0][2] - conic[0][0] * conic[1][2]) / determinant
+ const centeredConstant = conic[2][2] + conic[0][2] * centerX + conic[1][2] * centerY
+ const scale = -centeredConstant
+ if (Math.abs(scale) < 1e-12) return null
+
+ const shapeXX = conic[0][0] / scale
+ const shapeXY = conic[0][1] / scale
+ const shapeYY = conic[1][1] / scale
+ const shapeDeterminant = shapeXX * shapeYY - shapeXY * shapeXY
+ if (shapeDeterminant <= 0) return null
+
+ return ellipseProjection(
+ centerX,
+ centerY,
+ shapeYY / shapeDeterminant,
+ -shapeXY / shapeDeterminant,
+ shapeXX / shapeDeterminant
+ )
+}
+
+const projectedEllipsoidPath = (pose: AvatarPose, surface: SurfaceConfig) => {
+ const ellipse = projectedEllipsoid(pose, [
+ surface.width / 2,
+ surface.height / 2,
+ surface.depth / 2,
+ ])
+ const isSphere = surface.width === surface.height && surface.height === surface.depth
+ if (ellipse && isSphere) {
+ const radius = (ellipse.majorRadius + ellipse.minorRadius) / 2
+ return ellipsePath({
+ centerX: 0,
+ centerY: 0,
+ majorRadius: radius,
+ minorRadius: radius,
+ rotation: 0,
+ })
+ }
+ return ellipse ? ellipsePath(ellipse) : null
+}
+
+const mickeyEarPaths = (pose: AvatarPose, surface: SurfaceConfig) => {
+ if (surface.type !== 'mickey') return []
+
+ const radius = Math.min(surface.width, surface.height) * 0.23
+ const depthRadius = Math.min(radius, surface.depth * 0.29)
+ const centerX = surface.width * 0.37
+ const centerY = -surface.height * 0.39
+ const centerZ = -surface.depth * 0.12
+ const axes: Point3 = [radius, radius, depthRadius]
+ return [-1, 1]
+ .map(side => projectedEllipsoid(pose, axes, [side * centerX, centerY, centerZ]))
+ .filter((ear): ear is ProjectedEllipse => ear !== null)
+ .map(ellipsePath)
+}
+
+const compositeBackPaths = (pose: AvatarPose, surface: SurfaceConfig) => {
+ if (surface.type === 'mickey') return mickeyEarPaths(pose, surface)
+ if (surface.type === 'cursor') return [projectedCursorConePath(pose, surface)]
+ return []
+}
+
+const ellipsePoints = (ellipse: ProjectedEllipse) =>
+ Array.from({ length: PRIMITIVE_RING_SAMPLES }, (_, index) => {
+ const angle = (index / PRIMITIVE_RING_SAMPLES) * Math.PI * 2
+ const major = Math.cos(angle) * ellipse.majorRadius
+ const minor = Math.sin(angle) * ellipse.minorRadius
+ return [
+ ellipse.centerX + major * Math.cos(ellipse.rotation) - minor * Math.sin(ellipse.rotation),
+ ellipse.centerY + major * Math.sin(ellipse.rotation) + minor * Math.cos(ellipse.rotation),
+ 0,
+ ] as Point3
+ })
+
+const smoothHullPath = (points: Point3[]) => {
+ if (points.length < 3) return path(points)
+ const distances = points.map((point, index) => {
+ const next = points[(index + 1) % points.length]
+ return Math.hypot(next[0] - point[0], next[1] - point[1])
+ })
+ const sortedDistances = [...distances].sort((left, right) => left - right)
+ const medianDistance = sortedDistances[Math.floor(sortedDistances.length / 2)] || 1
+ const straightThreshold = Math.max(8, medianDistance * 3.5)
+ const straightEdges = distances.map(distance => distance > straightThreshold)
+
+ return `M${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}${points
+ .map((point, index) => {
+ const nextIndex = (index + 1) % points.length
+ const next = points[nextIndex]
+ if (straightEdges[index]) return `L${next[0].toFixed(2)} ${next[1].toFixed(2)}`
+ const previous = straightEdges[(index - 1 + points.length) % points.length]
+ ? point
+ : points[(index - 1 + points.length) % points.length]
+ const afterNext = straightEdges[nextIndex] ? next : points[(index + 2) % points.length]
+ const firstControlX = point[0] + (next[0] - previous[0]) / 6
+ const firstControlY = point[1] + (next[1] - previous[1]) / 6
+ const secondControlX = next[0] - (afterNext[0] - point[0]) / 6
+ const secondControlY = next[1] - (afterNext[1] - point[1]) / 6
+ return `C${firstControlX.toFixed(2)} ${firstControlY.toFixed(2)} ${secondControlX.toFixed(2)} ${secondControlY.toFixed(2)} ${next[0].toFixed(2)} ${next[1].toFixed(2)}`
+ })
+ .join('')}Z`
+}
+
+const projectedCapsulePath = (pose: AvatarPose, surface: SurfaceConfig) => {
+ const radiusX = surface.width / 2
+ const radiusY = Math.min(radiusX, surface.height / 2)
+ const radiusZ = surface.depth / 2
+ const straightHalf = Math.max(0, (surface.height - radiusY * 2) / 2)
+ const axes: Point3 = [radiusX, radiusY, radiusZ]
+ const top = projectedEllipsoid(pose, axes, [0, straightHalf, 0])
+ const bottom = projectedEllipsoid(pose, axes, [0, -straightHalf, 0])
+ if (!top || !bottom) return null
+ return smoothHullPath(convexHull([...ellipsePoints(top), ...ellipsePoints(bottom)]))
+}
+
+const headPath = (pose: AvatarPose, surface: SurfaceConfig) => {
+ if (surface.type === 'sphere' || surface.type === 'mickey') {
+ const exactPath = projectedEllipsoidPath(pose, surface)
+ if (exactPath) return exactPath
+ }
+
+ if (surface.type === 'capsule') {
+ const exactPath = projectedCapsulePath(pose, surface)
+ if (exactPath) return exactPath
+ }
+
+ if (surface.type === 'cylinder') return projectedCylinderPath(pose, surface)
+ if (surface.type === 'cursor') return projectedCursorBodyPath(pose, surface)
+ if (surface.type === 'cone') return projectedConePath(pose, surface)
+ if (surface.type === 'cube') return projectedCubePath(pose, surface)
+ if (surface.type === 'diamond') return projectedDiamondPath(pose, surface)
+
+ const key = surfaceCacheKey(surface)
+ let localSamples = headSamplesCache.get(key)
+ if (!localSamples) {
+ localSamples = Array.from({ length: HEAD_LATITUDE_SAMPLES }, (_, latitudeIndex) => {
+ const latitude = -Math.PI / 2 + (latitudeIndex / (HEAD_LATITUDE_SAMPLES - 1)) * Math.PI
+ return Array.from({ length: HEAD_LONGITUDE_SAMPLES }, (_, longitudeIndex) => {
+ const longitude = -Math.PI + (longitudeIndex / (HEAD_LONGITUDE_SAMPLES - 1)) * Math.PI * 2
+ return surfacePointAt(surface, longitude, latitude)
+ })
+ }).flat()
+ cacheSurfaceValue(headSamplesCache, key, localSamples)
+ }
+ const projectedSamples = localSamples.map(sample =>
+ project(rotateWithQuaternion(pose.orientation, sample), pose.expression.perspective)
+ )
+ return path(convexHull(projectedSamples))
+}
+
+const accessoryPath = (pose: AvatarPose, node: BodyNode) => {
+ const key = surfaceCacheKey(node.surface)
+ let localSamples = accessorySamplesCache.get(key)
+ if (!localSamples) {
+ localSamples = Array.from({ length: 17 }, (_, latitudeIndex) => {
+ const latitude = -Math.PI / 2 + (latitudeIndex / 16) * Math.PI
+ return Array.from({ length: 49 }, (_, longitudeIndex) => {
+ const longitude = -Math.PI + (longitudeIndex / 48) * Math.PI * 2
+ return surfacePointAt(node.surface, longitude, latitude)
+ })
+ }).flat()
+ cacheSurfaceValue(accessorySamplesCache, key, localSamples)
+ }
+
+ const localOrientation = quaternionFromEuler(
+ radians(node.rotation[0]),
+ radians(node.rotation[1]),
+ radians(node.rotation[2])
+ )
+ const projected = localSamples.map(point => {
+ const locallyRotated = rotateWithQuaternion(localOrientation, point)
+ const positioned: Point3 = [
+ locallyRotated[0] + node.position[0],
+ locallyRotated[1] + node.position[1],
+ locallyRotated[2] + node.position[2],
+ ]
+ return project(rotateWithQuaternion(pose.orientation, positioned), pose.expression.perspective)
+ })
+ const hull = convexHull(projected)
+ if (
+ (node.surface.type === 'cube' || node.surface.type === 'diamond') &&
+ node.surface.roundness <= 0
+ ) {
+ return path(hull)
+ }
+ return smoothClosedPath(densifyClosedPoints(hull))
+}
+
+const ACCESSORY_FRONT_CROSSING_RATIO = 0.1
+
+const accessoryCameraDepthRadius = (pose: AvatarPose, node: BodyNode) => {
+ const localOrientation = quaternionFromEuler(
+ radians(node.rotation[0]),
+ radians(node.rotation[1]),
+ radians(node.rotation[2])
+ )
+ const cameraDepthByAxis = (
+ [
+ [1, 0, 0],
+ [0, 1, 0],
+ [0, 0, 1],
+ ] as Point3[]
+ ).map(
+ axis => rotateWithQuaternion(pose.orientation, rotateWithQuaternion(localOrientation, axis))[2]
+ )
+ return Math.hypot(
+ cameraDepthByAxis[0] * (node.surface.width / 2),
+ cameraDepthByAxis[1] * (node.surface.height / 2),
+ cameraDepthByAxis[2] * (node.surface.depth / 2)
+ )
+}
+
+const accessoryLayers = (pose: AvatarPose, nodes: BodyNode[]) => {
+ const layers = nodes
+ .map(node => {
+ const depth = rotateWithQuaternion(pose.orientation, node.position)[2]
+ return {
+ id: node.id,
+ path: accessoryPath(pose, node),
+ depth,
+ front: depth > accessoryCameraDepthRadius(pose, node) * ACCESSORY_FRONT_CROSSING_RATIO,
+ }
+ })
+ .sort((left, right) => left.depth - right.depth)
+ return {
+ backPaths: layers.filter(layer => !layer.front).map(layer => layer.path),
+ frontPaths: layers.filter(layer => layer.front).map(layer => layer.path),
+ backNodeIds: layers.filter(layer => !layer.front).map(layer => layer.id),
+ frontNodeIds: layers.filter(layer => layer.front).map(layer => layer.id),
+ }
+}
+
+export const renderAvatar = (
+ pose: AvatarPose,
+ surface: SurfaceConfig,
+ blink = 1,
+ options: RenderAvatarOptions = {}
+): AvatarGeometry => {
+ const leftSamples = eyePoints(pose, surface, -1, blink)
+ const rightSamples = eyePoints(pose, surface, 1, blink)
+ const left = leftSamples.map(sample => sample.point)
+ const right = rightSamples.map(sample => sample.point)
+ const accessories = accessoryLayers(pose, options.bodyNodes ?? [])
+ const compositePaths = compositeBackPaths(pose, surface)
+ return {
+ backPaths: [...compositePaths, ...accessories.backPaths],
+ frontPaths: accessories.frontPaths,
+ backNodeIds: [...compositePaths.map(() => null), ...accessories.backNodeIds],
+ frontNodeIds: accessories.frontNodeIds,
+ headPath: headPath(pose, surface),
+ leftPath: path(left),
+ rightPath: path(right),
+ leftVisible: leftSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0,
+ rightVisible: rightSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0,
+ wirePaths: options.includeWire === false ? [] : wirePaths(pose, surface),
+ }
+}
diff --git a/src/i18n.ts b/src/i18n.ts
new file mode 100644
index 0000000..7abab67
--- /dev/null
+++ b/src/i18n.ts
@@ -0,0 +1,514 @@
+import {
+ createContext,
+ createElement,
+ useContext,
+ useEffect,
+ useState,
+ type ReactNode,
+} from 'react'
+import { chinese, translateChineseDynamicText } from './i18n.zh'
+
+export type StudioLanguage = 'en' | 'fr' | 'zh-CN'
+
+const STUDIO_LANGUAGE_STORAGE_KEY = 'avatar-studio-language'
+
+const isStudioLanguage = (value: string | null): value is StudioLanguage =>
+ value === 'en' || value === 'fr' || value === 'zh-CN'
+
+const readStoredStudioLanguage = (): StudioLanguage => {
+ if (typeof window === 'undefined') return 'en'
+ try {
+ const storedLanguage = window.localStorage.getItem(STUDIO_LANGUAGE_STORAGE_KEY)
+ return isStudioLanguage(storedLanguage) ? storedLanguage : 'en'
+ } catch {
+ return 'en'
+ }
+}
+
+const english: Record = {
+ 'Playground de l’avatar': 'Avatar playground',
+ Arrêté: 'Stopped',
+ 'Aperçu procédural en direct': 'Live procedural preview',
+ 'Aperçu de l’avatar': 'Avatar preview',
+ 'Choisissez une animation exportée pour la lancer.': 'Choose an exported animation to play it.',
+ Lecture: 'Playback',
+ 'Contrôlez l’animation active.': 'Control the active animation.',
+ Lire: 'Play',
+ Arrêter: 'Stop',
+ 'API JavaScript': 'JavaScript API',
+ 'Le module réutilisable est disponible dans avatar.js.':
+ 'The reusable module is available as avatar.js.',
+ 'Ouvrez cette démo directement, ou servez le dossier localement pour importer avatar.js depuis un autre module.':
+ 'Open this demo directly, or serve the folder locally to import avatar.js from another module.',
+ 'Langue de l’interface': 'Interface language',
+ 'Nom de l’avatar': 'Avatar name',
+ sélecteur: 'picker',
+ hexadécimal: 'hex value',
+ 'Lier width': 'Link widths',
+ 'Lier height': 'Link heights',
+ 'Lier size': 'Link sizes',
+ 'Lier la position des yeux': 'Link eye positions',
+ 'Glisser horizontalement pour modifier': 'Drag horizontally to adjust',
+ 'En lecture': 'Playing',
+ 'En pause': 'Paused',
+ 'Réinitialiser la rotation de la tête': 'Reset head rotation',
+ 'Réinitialiser la position et la rotation de': 'Reset position and rotation of',
+ 'Avatar procédural': 'Procedural avatar',
+ 'Gizmo de rotation': 'Rotation gizmo',
+ 'Retour aux expressions': 'Back to expressions',
+ 'Retour aux animations': 'Back to animations',
+ 'Retour au studio': 'Back to studio',
+ 'Preset en mémoire': 'Saved preset',
+ 'Nouvelle expression': 'New expression',
+ 'L’avatar à gauche affiche cette expression en direct.':
+ 'The avatar on the left previews this expression live.',
+ Corps: 'Body',
+ 'Apparence et orientation générale de l’avatar.': 'General avatar appearance and orientation.',
+ 'Couleur du corps': 'Body color',
+ 'Rotation de la tête': 'Head rotation',
+ Yeux: 'Eyes',
+ 'Forme, placement et orientation propres au regard.': 'Eye shape, placement and orientation.',
+ 'Couleur des yeux': 'Eye color',
+ Largeur: 'Width',
+ Hauteur: 'Height',
+ Échelle: 'Scale',
+ 'Taille proportionnelle': 'Proportional size',
+ 'Œil gauche': 'Left eye',
+ 'Œil droit': 'Right eye',
+ 'Position et espacement': 'Position and spacing',
+ Horizontale: 'Horizontal',
+ Verticale: 'Vertical',
+ Espacement: 'Spacing',
+ 'Rotation locale': 'Local rotation',
+ Projection: 'Projection',
+ 'Perspective appliquée à la surface active.': 'Perspective applied to the active surface.',
+ Perspective: 'Perspective',
+ Supprimer: 'Delete',
+ Enregistrer: 'Save',
+ 'Construction du corps': 'Body construction',
+ 'Choisis la forme principale puis assemble les primitives autour d’elle.':
+ 'Choose the primary shape, then assemble primitives around it.',
+ 'Choisir un avatar': 'Choose an avatar',
+ 'Double-clic pour modifier': 'Double-click to edit',
+ 'Nouvel avatar': 'New avatar',
+ Modifier: 'Edit',
+ 'Mode d’édition': 'Editing mode',
+ Pose: 'Pose',
+ Expressions: 'Expressions',
+ Animations: 'Animations',
+ Exporter: 'Export',
+ 'Exporter l’avatar': 'Export avatar',
+ 'Télécharge un composant autonome avec les animations de ton choix.':
+ 'Download a standalone component with the animations you choose.',
+ 'Avatar sélectionné': 'Selected avatar',
+ Format: 'Format',
+ 'Choisis l’intégration correspondant à ton projet.':
+ 'Choose the integration that matches your project.',
+ 'Composant TSX autonome': 'Standalone TSX component',
+ 'Package React local (.zip)': 'Local React package (.zip)',
+ 'Module JavaScript': 'JavaScript module',
+ 'Module ES autonome': 'Standalone ES module',
+ 'Projet HTML + module JS (.zip)': 'HTML project + JS module (.zip)',
+ sélectionnées: 'selected',
+ 'Animations à exporter': 'Animations to export',
+ 'Tout sélectionner': 'Select all',
+ 'Tout désélectionner': 'Deselect all',
+ 'Télécharger le composant TSX': 'Download TSX component',
+ 'Télécharger le package React': 'Download React package',
+ 'Télécharger le module': 'Download module',
+ Snapshot: 'Snapshot',
+ 'Mode photo': 'Photo Mode',
+ 'Capture une image statique de l’avatar.': 'Capture a static image of the avatar.',
+ 'Les options de capture seront configurées ici.': 'Snapshot options will be configured here.',
+ 'Aperçu du Snapshot': 'Snapshot preview',
+ 'Aperçu du mode photo': 'Photo Mode preview',
+ 'Arrière-plan': 'Background',
+ 'Choisis un fond transparent, uni ou en dégradé.':
+ 'Choose a transparent, solid or gradient background.',
+ Style: 'Style',
+ Transparent: 'Transparent',
+ Uni: 'Solid',
+ 'Dégradé linéaire': 'Linear gradient',
+ 'Dégradé radial': 'Radial gradient',
+ 'Style d’arrière-plan': 'Background style',
+ Couleur: 'Color',
+ Départ: 'Start',
+ Arrivée: 'End',
+ Définition: 'Resolution',
+ 'Dimensions inscrites dans le fichier SVG.': 'Dimensions embedded in the SVG file.',
+ 'Dimensions du fichier exporté.': 'Dimensions of the exported file.',
+ 'Définition du Snapshot': 'Snapshot resolution',
+ 'Définition du mode photo': 'Photo Mode resolution',
+ 'Télécharger le Snapshot SVG': 'Download SVG snapshot',
+ 'Télécharger en SVG': 'Download SVG',
+ 'Télécharger en PNG': 'Download PNG',
+ 'Format d’export': 'Export format',
+ 'Format d’export du mode photo': 'Photo Mode export format',
+ 'Choisis le type de fichier généré par le mode photo.':
+ 'Choose the file type generated by Photo Mode.',
+ 'Prendre une photo': 'Take a picture',
+ 'Informations sur le mode photo': 'Photo Mode information',
+ 'Tu peux modifier le format, le fond et la définition du mode photo dans Export.':
+ 'You can change the format, background and resolution for Photo Mode in Export.',
+ 'Projet du Studio': 'Studio project',
+ 'Transfère tous les avatars, expressions et animations vers un autre navigateur.':
+ 'Transfer every avatar, expression and animation to another browser.',
+ 'Télécharger le projet JSON': 'Download JSON project',
+ 'Importer un projet JSON': 'Import JSON project',
+ 'Ce fichier ne contient pas un projet Avatar Studio valide et compatible.':
+ 'This file does not contain a valid, compatible Avatar Studio project.',
+ 'Importer ce projet ?': 'Import this project?',
+ 'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.':
+ 'The current local project will be replaced with the avatars, expressions, animations and playback state from this file.',
+ 'Le projet n’a pas pu être enregistré dans ce navigateur. Libère de l’espace puis réessaie.':
+ 'The project could not be saved in this browser. Free up some space and try again.',
+ Importer: 'Import',
+ 'Construction, forme et couleur de la tête de l’avatar.':
+ 'Build, shape and color of the avatar head.',
+ 'Une forme principale porte les yeux. Les autres primitives se placent autour d’elle.':
+ 'One primary shape carries the eyes. Other primitives are placed around it.',
+ 'Forme principale': 'Primary shape',
+ 'porte les yeux': 'carries the eyes',
+ 'Ajouter une forme': 'Add a shape',
+ Dupliquer: 'Duplicate',
+ 'Gizmo local': 'Local gizmo',
+ 'Déplacer dans le plan de la caméra': 'Move in camera plane',
+ Transformer: 'Transform',
+ copie: 'copy',
+ 'Glisse un axe pour déplacer la forme, ou un anneau pour la faire tourner.':
+ 'Drag an axis to move the shape, or a ring to rotate it.',
+ Profondeur: 'Depth',
+ 'Position locale': 'Local position',
+ 'Cette surface est la référence du visage et porte les yeux.':
+ 'This surface is the face reference and carries the eyes.',
+ 'Rondeur des arêtes': 'Edge roundness',
+ Rondeur: 'Roundness',
+ 'Rondeur morphologique': 'Morph roundness',
+ 'Rondeur de la pointe': 'Tip roundness',
+ 'Rondeur de la base': 'Base roundness',
+ 'Rondeur globale': 'Global roundness',
+ 'Rondeur pointe': 'Tip roundness',
+ 'Rondeur base': 'Base roundness',
+ 'Couleur de base utilisée par les poses et les expressions.':
+ 'Base color used by poses and expressions.',
+ 'Forme, placement, orientation et couleur du regard par défaut.':
+ 'Default eye shape, placement, orientation and color.',
+ 'Définis l’identité du regard de cet avatar. Les poses s’ajoutent ensuite à cette base.':
+ 'Define this avatar’s default eyes. Poses are then applied on top of this base.',
+ 'Coordonnées propres à l’avatar, indépendantes des poses.':
+ 'Avatar-specific coordinates, independent from poses.',
+ 'Inclinaison par défaut propre à chaque œil.': 'Default tilt for each eye.',
+ 'Orientation et apparence générale de la pose.': 'General pose orientation and appearance.',
+ 'La pose peut remplacer temporairement la couleur de l’avatar.':
+ 'The pose can temporarily override the avatar color.',
+ 'Reprendre la couleur de l’avatar': 'Use avatar color',
+ 'Les libellés ↔ sont scrubbables, comme dans Figma.':
+ 'Labels marked ↔ can be scrubbed, like in Figma.',
+ 'Forme, placement, orientation et couleur du regard.':
+ 'Eye shape, placement, orientation and color.',
+ 'Coordonnées communes projetées sur la forme choisie.':
+ 'Shared coordinates projected onto the selected shape.',
+ 'Inclinaison propre à chaque œil.': 'Tilt applied to each eye.',
+ 'Perspective et repères appliqués à la surface active.':
+ 'Perspective and guides applied to the active surface.',
+ 'Profondeur simulée du visage.': 'Simulated face depth.',
+ 'Afficher le maillage': 'Show wireframe',
+ Réinitialiser: 'Reset',
+ Mouvement: 'Motion',
+ 'Mouvement perpétuel': 'Perpetual motion',
+ 'Aucun mouvement': 'No motion',
+ 'Dérive lente': 'Slow drift',
+ 'Micro-ajustements': 'Micro-adjustments',
+ Tremblement: 'Shake',
+ 'Ajoute une légère présence ou un tremblement continu au corps.':
+ 'Adds a subtle living presence or a continuous shake to the body.',
+ 'Anime le regard par petites saccades naturelles ou par tremblement.':
+ 'Animates the gaze with natural micro-saccades or a continuous shake.',
+ 'Motion interpole les valeurs et notre moteur effectue le slerp quaternion.':
+ 'Motion interpolates values while the engine performs quaternion slerp.',
+ 'Vitesse du ressort': 'Spring speed',
+ 'Une animation est en cours de lecture': 'An animation is currently playing',
+ 'Mettez l’animation en pause avant de manipuler les paramètres de pose.':
+ 'Pause the animation before adjusting pose parameters.',
+ 'Mettez l’animation en pause avant de choisir ou personnaliser une expression.':
+ 'Pause the animation before selecting or customizing an expression.',
+ Cligner: 'Blink',
+ 'Expression aléatoire': 'Random expression',
+ 'Cycle de vie': 'Life cycle',
+ Réactions: 'Reactions',
+ 'Éditeur d’animation': 'Animation editor',
+ 'Modifier l’animation': 'Edit animation',
+ 'Nouvelle animation': 'New animation',
+ 'Compose les expressions, leur cadence et les clignements de cette animation.':
+ 'Compose the expressions, timing and blinks for this animation.',
+ 'Prévisualiser l’animation': 'Preview animation',
+ 'Arrêter l’animation': 'Stop animation',
+ Identité: 'Identity',
+ 'Nom, catégorie et comportement de lecture de l’animation.':
+ 'Animation name, category and playback behavior.',
+ Nom: 'Name',
+ Catégorie: 'Category',
+ Description: 'Description',
+ 'Mode de lecture': 'Playback mode',
+ Boucle: 'Loop',
+ 'Une fois': 'Once',
+ 'Aller-retour': 'Ping-pong',
+ Timeline: 'Timeline',
+ 'Glisse les étapes pour les réordonner, puis sélectionne-en une pour régler sa cadence.':
+ 'Drag steps to reorder them, then select one to adjust its timing.',
+ 'Ajoute une expression pour commencer.': 'Add an expression to get started.',
+ 'Étape sélectionnée': 'Selected step',
+ 'Durée visible avant de passer à l’expression suivante.':
+ 'Visible duration before moving to the next expression.',
+ 'Supprimer cette étape': 'Delete this step',
+ 'Temps d’affichage': 'Display time',
+ 'Durée de transition': 'Transition duration',
+ Transition: 'Transition',
+ Ressort: 'Spring',
+ Douce: 'Smooth',
+ Rapide: 'Snappy',
+ 'Ajouter une expression': 'Add expression',
+ 'Sélectionne un preset pour l’ajouter à la fin de la timeline.':
+ 'Select a preset to append it to the timeline.',
+ Clignements: 'Blinks',
+ 'Le blink fonctionne indépendamment des changements d’expression.':
+ 'Blinking runs independently from expression changes.',
+ 'Activer les clignements': 'Enable blinking',
+ 'Intervalle minimum': 'Minimum interval',
+ 'Intervalle maximum': 'Maximum interval',
+ 'Intervalle du clignement': 'Blink interval',
+ 'Durée du clignement': 'Blink duration',
+ animations: 'animations',
+ 'Chaque étape possède sa propre durée et sa propre transition.':
+ 'Each step has its own duration and transition.',
+ Désactivé: 'Disabled',
+ 'comportement de la timeline': 'timeline behavior',
+ 'Supprimer cette animation ?': 'Delete this animation?',
+ 'Cette action supprimera définitivement cette animation.':
+ 'This permanently deletes this animation.',
+ 'Cette animation enchaîne un pool de presets et des clignements.':
+ 'This animation cycles through presets and blinks.',
+ 'Cet état enchaîne un pool de presets et des clignements.':
+ 'This animation cycles through presets and blinks.',
+ 'Expressions de l’animation': 'Animation expressions',
+ 'Les presets sont joués dans cet ordre, puis la boucle recommence.':
+ 'Presets play in this order, then the loop starts again.',
+ 'Logique de clignement': 'Blink behavior',
+ 'Le rythme reste naturel grâce à un intervalle légèrement aléatoire.':
+ 'A slightly randomized interval keeps the rhythm natural.',
+ 'Premier clignement': 'First blink',
+ 'après le lancement': 'after launch',
+ Intervalle: 'Interval',
+ 'tirage aléatoire': 'randomized',
+ Durée: 'Duration',
+ 'fermeture et ouverture': 'close and open',
+ 'Changement d’expression': 'Expression change',
+ 'cadence de l’animation': 'animation tempo',
+ 'Détails de l’animation': 'Animation details',
+ 'Afficher les détails de l’animation': 'Show animation details',
+ 'Masquer les détails de l’animation': 'Hide animation details',
+ Relancer: 'Restart',
+ Lancer: 'Play',
+ Pause: 'Pause',
+ Reprendre: 'Resume',
+ Annuler: 'Cancel',
+ 'Supprimer cette expression ?': 'Delete this expression?',
+ 'Cette action retirera définitivement le preset de la bibliothèque de cet avatar.':
+ 'This permanently removes the preset from this avatar’s library.',
+ 'Cette expression sera aussi retirée des animations suivantes :':
+ 'This expression will also be removed from the following animations:',
+ 'Si une animation ne contient que cette expression, l’expression de repli lui sera assignée pour qu’elle reste jouable.':
+ 'If an animation only contains this expression, the fallback expression will be assigned so it remains playable.',
+ 'Le corps, les expressions et les animations propres à cet avatar seront définitivement supprimés. La bibliothèque de base sera conservée.':
+ 'This avatar’s body, expressions and animations will be permanently deleted. The base library will be preserved.',
+ 'Glisse sur la surface pour orienter la tête. Les anneaux du gizmo contrôlent X, Y et Z.':
+ 'Drag on the surface to orient the head. The gizmo rings control X, Y and Z.',
+ Sphère: 'Sphere',
+ Curseur: 'Cursor',
+ Cylindre: 'Cylinder',
+ Cône: 'Cone',
+ Diamant: 'Diamond',
+ 'Yeux presque fermés, respiration lente et expression de sommeil.':
+ 'Nearly closed eyes, slow breathing and a sleepy expression.',
+ 'Animation courte de réveil avant retour vers une expression neutre.':
+ 'Short waking animation before returning to a neutral expression.',
+ 'Séquence courte de réveil avant retour vers une expression neutre.':
+ 'Short waking animation before returning to a neutral expression.',
+ 'Micro-mouvements lents, expressions 00 et 08, clignement rare.':
+ 'Slow micro-movements, expressions 00 and 08, infrequent blinking.',
+ 'Expressions 10, 01 et 19, regard stable et clignement attentif.':
+ 'Expressions 10, 01 and 19, steady gaze and attentive blinking.',
+ 'Regard haut et latéral, expressions asymétriques et changements fréquents.':
+ 'Upward side gaze, asymmetric expressions and frequent changes.',
+ 'Balayage rapide et changements très fréquents.': 'Fast scanning and very frequent changes.',
+ 'Rythme régulier, regard concentré et micro-variations.':
+ 'Steady rhythm, focused gaze and subtle variations.',
+ 'Grandes expressions positives et mouvements rapides.':
+ 'Big positive expressions and fast movements.',
+ 'Inclinaisons et rotations rapides pour simuler un agent en mouvement.':
+ 'Fast tilts and rotations to simulate a moving agent.',
+ 'Rythme régulier et expressions concentrées.': 'Steady rhythm and focused expressions.',
+ 'Grandes expressions et transitions rapides.': 'Big expressions and fast transitions.',
+ 'Inclinaisons et forte asymétrie.': 'Tilts and strong asymmetry.',
+ sleeping: 'sleeping',
+ waking: 'waking',
+ idle: 'idle',
+ listening: 'listening',
+ thinking: 'thinking',
+ searching: 'searching',
+ working: 'working',
+ excited: 'excited',
+ surprised: 'surprised',
+ suspicious: 'suspicious',
+ angry: 'angry',
+ drowsy: 'drowsy',
+ happy: 'happy',
+ curious: 'curious',
+ confused: 'confused',
+ bored: 'bored',
+ proud: 'proud',
+ shy: 'shy',
+ sad: 'sad',
+ laughing: 'laughing',
+ scared: 'scared',
+ playful: 'playful',
+ celebrate: 'celebrate',
+ orbit: 'orbit',
+ radar: 'radar',
+ progress: 'progress',
+ spawning: 'spawning',
+ humming: 'humming',
+ loading: 'loading',
+ dictating: 'dictating',
+ writing: 'writing',
+ sending: 'sending',
+ receiving: 'receiving',
+ uploading: 'uploading',
+ notifying: 'notifying',
+ alerting: 'alerting',
+ dragging: 'dragging',
+ bouncing: 'bouncing',
+ 'powering-down': 'powering down',
+}
+
+const frenchStates: Record = {
+ loop: 'boucle',
+ once: 'une fois',
+ pingPong: 'aller-retour',
+ Custom: 'Personnalisé',
+ 'Untitled animation': 'Animation sans titre',
+ // Compatibilité avec les animations intégrées déjà persistées avant le renommage.
+ 'Untitled sequence': 'Animation sans titre',
+ 'Cet état enchaîne un pool de presets et des clignements.':
+ 'Cette animation enchaîne un pool de presets et des clignements.',
+ 'Séquence courte de réveil avant retour vers une expression neutre.':
+ 'Animation courte de réveil avant retour vers une expression neutre.',
+ sleeping: 'sommeil',
+ waking: 'réveil',
+ idle: 'au repos',
+ listening: 'écoute',
+ thinking: 'réflexion',
+ searching: 'recherche',
+ working: 'travail',
+ excited: 'enthousiaste',
+ surprised: 'surpris',
+ suspicious: 'méfiant',
+ angry: 'en colère',
+ drowsy: 'somnolent',
+ happy: 'heureux',
+ curious: 'curieux',
+ confused: 'confus',
+ bored: 'ennuyé',
+ proud: 'fier',
+ shy: 'timide',
+ sad: 'triste',
+ laughing: 'rire',
+ scared: 'effrayé',
+ playful: 'joueur',
+ celebrate: 'célébration',
+ orbit: 'orbite',
+ radar: 'radar',
+ progress: 'progression',
+ spawning: 'apparition',
+ humming: 'fredonnement',
+ loading: 'chargement',
+ dictating: 'dictée',
+ writing: 'écriture',
+ sending: 'envoi',
+ receiving: 'réception',
+ uploading: 'téléversement',
+ notifying: 'notification',
+ alerting: 'alerte',
+ dragging: 'glissement',
+ bouncing: 'rebond',
+ 'powering-down': 'extinction',
+}
+
+const dynamicTranslations: [RegExp, string][] = [
+ [/^Modifier l’expression (.+)$/, 'Edit expression $1'],
+ [/^Modifier (.+)$/, 'Edit $1'],
+ [/^Supprimer (.+) \?$/, 'Delete $1?'],
+ [/^Ajouter une forme · (.+)$/, 'Add a shape · $1'],
+ [/^Réinitialiser la position et la rotation de (.+)$/, 'Reset position and rotation of $1'],
+ [/^Animation en cours : (.+)$/, 'Active animation: $1'],
+ [/^Mettre (.+) en pause$/, 'Pause $1'],
+ [/^Reprendre (.+)$/, 'Resume $1'],
+ [/^Arrêter (.+)$/, 'Stop $1'],
+ [/^(.+) expressions$/, '$1 expressions'],
+]
+
+export const translateStudioText = (text: string, language: StudioLanguage) => {
+ if (language === 'fr') return frenchStates[text] ?? text
+ if (language === 'zh-CN') {
+ const exact = chinese[text]
+ if (exact) return exact
+ const dynamic = translateChineseDynamicText(text)
+ if (dynamic) return dynamic
+ return Object.entries(chinese)
+ .sort(([left], [right]) => right.length - left.length)
+ .reduce(
+ (translated, [source, replacement]) => translated.replaceAll(source, replacement),
+ text
+ )
+ }
+ const exact = english[text]
+ if (exact) return exact
+ for (const [pattern, replacement] of dynamicTranslations) {
+ if (pattern.test(text)) return text.replace(pattern, replacement)
+ }
+ return Object.entries(english)
+ .sort(([left], [right]) => right.length - left.length)
+ .reduce((translated, [source, replacement]) => translated.replaceAll(source, replacement), text)
+}
+
+type StudioLanguageContextValue = {
+ language: StudioLanguage
+ setLanguage: (language: StudioLanguage) => void
+ t: (text: string) => string
+}
+
+const StudioLanguageContext = createContext(null)
+
+export function StudioLanguageProvider({ children }: { children: ReactNode }) {
+ const [language, setLanguage] = useState(readStoredStudioLanguage)
+
+ useEffect(() => {
+ try {
+ window.localStorage.setItem(STUDIO_LANGUAGE_STORAGE_KEY, language)
+ } catch {
+ // The studio remains usable when browser storage is unavailable.
+ }
+ }, [language])
+
+ return createElement(
+ StudioLanguageContext.Provider,
+ { value: { language, setLanguage, t: text => translateStudioText(text, language) } },
+ children
+ )
+}
+
+export const useStudioLanguage = () => {
+ const context = useContext(StudioLanguageContext)
+ if (!context) throw new Error('useStudioLanguage must be used inside StudioLanguageProvider')
+ return context
+}
diff --git a/src/i18n.zh.ts b/src/i18n.zh.ts
new file mode 100644
index 0000000..fba2909
--- /dev/null
+++ b/src/i18n.zh.ts
@@ -0,0 +1,377 @@
+export const chinese: Record = {
+ 'Playground de l’avatar': '头像游乐场',
+ Arrêté: '已停止',
+ 'Aperçu procédural en direct': '实时程序化预览',
+ 'Aperçu de l’avatar': '头像预览',
+ 'Choisissez une animation exportée pour la lancer。': '选择一个已导出的动画以播放。',
+ 'Choisissez une animation exportée pour la lancer.': '选择一个已导出的动画以播放。',
+ Lecture: '播放',
+ 'Contrôlez l’animation active.': '控制当前动画。',
+ Lire: '播放',
+ Arrêter: '停止',
+ 'API JavaScript': 'JavaScript API',
+ 'Le module réutilisable est disponible dans avatar.js.': '可复用模块位于 avatar.js。',
+ 'Ouvrez cette démo directement, ou servez le dossier localement pour importer avatar.js depuis un autre module.':
+ '可直接打开此演示,或在本地启动该目录,以便从其他模块导入 avatar.js。',
+ 'Langue de l’interface': '界面语言',
+ 'Nom de l’avatar': '头像名称',
+ sélecteur: '选择器',
+ hexadécimal: '十六进制值',
+ 'Lier width': '关联宽度',
+ 'Lier height': '关联高度',
+ 'Lier size': '关联尺寸',
+ 'Lier la position des yeux': '关联眼睛位置',
+ 'Glisser horizontalement pour modifier': '水平拖动以调整',
+ 'En lecture': '播放中',
+ 'En pause': '已暂停',
+ 'Réinitialiser la rotation de la tête': '重置头部旋转',
+ 'Réinitialiser la position et la rotation de': '重置位置和旋转:',
+ 'Avatar procédural': '程序化头像',
+ 'Gizmo de rotation': '旋转控制器',
+ 'Retour aux expressions': '返回表情',
+ 'Retour aux animations': '返回动画',
+ 'Retour au studio': '返回工作室',
+ 'Preset en mémoire': '已保存预设',
+ 'Nouvelle expression': '新建表情',
+ 'L’avatar à gauche affiche cette expression en direct.': '左侧头像会实时预览此表情。',
+ Corps: '身体',
+ 'Apparence et orientation générale de l’avatar.': '头像的整体外观和方向。',
+ 'Couleur du corps': '身体颜色',
+ 'Rotation de la tête': '头部旋转',
+ Yeux: '眼睛',
+ 'Forme, placement et orientation propres au regard.': '眼睛的形状、位置和方向。',
+ 'Couleur des yeux': '眼睛颜色',
+ Largeur: '宽度',
+ Hauteur: '高度',
+ Échelle: '缩放',
+ 'Taille proportionnelle': '等比尺寸',
+ 'Œil gauche': '左眼',
+ 'Œil droit': '右眼',
+ 'Position et espacement': '位置和间距',
+ Horizontale: '水平',
+ Verticale: '垂直',
+ Espacement: '间距',
+ 'Rotation locale': '局部旋转',
+ Projection: '投影',
+ 'Perspective appliquée à la surface active.': '应用于当前表面的透视。',
+ Perspective: '透视',
+ Supprimer: '删除',
+ Enregistrer: '保存',
+ 'Construction du corps': '身体构建',
+ 'Choisis la forme principale puis assemble les primitives autour d’elle.':
+ '选择主形状,然后在其周围组合基础形状。',
+ 'Choisir un avatar': '选择头像',
+ 'Double-clic pour modifier': '双击编辑',
+ 'Nouvel avatar': '新建头像',
+ Modifier: '编辑',
+ 'Mode d’édition': '编辑模式',
+ Pose: '姿势',
+ Expressions: '表情',
+ Animations: '动画',
+ Exporter: '导出',
+ Export: '导出',
+ 'Exporter l’avatar': '导出头像',
+ 'Télécharge un composant autonome avec les animations de ton choix.':
+ '下载包含所选动画的独立组件。',
+ 'Avatar sélectionné': '已选头像',
+ Format: '格式',
+ 'Choisis l’intégration correspondant à ton projet.': '选择适合项目的集成方式。',
+ 'Composant TSX autonome': '独立 TSX 组件',
+ 'Package React local (.zip)': '本地 React 包(.zip)',
+ 'Module JavaScript': 'JavaScript 模块',
+ 'Module ES autonome': '独立 ES 模块',
+ 'Projet HTML + module JS (.zip)': 'HTML 项目 + JS 模块(.zip)',
+ sélectionnées: '已选择',
+ 'Animations à exporter': '要导出的动画',
+ 'Tout sélectionner': '全选',
+ 'Tout désélectionner': '取消全选',
+ 'Télécharger le composant TSX': '下载 TSX 组件',
+ 'Télécharger le package React': '下载 React 包',
+ 'Télécharger le module': '下载模块',
+ Snapshot: '快照',
+ 'Mode photo': '照片模式',
+ 'Capture une image statique de l’avatar.': '捕获头像的静态图像。',
+ 'Les options de capture seront configurées ici.': '快照选项将在此配置。',
+ 'Aperçu du Snapshot': '快照预览',
+ 'Aperçu du mode photo': '照片模式预览',
+ 'Arrière-plan': '背景',
+ 'Choisis un fond transparent, uni ou en dégradé.': '选择透明、纯色或渐变背景。',
+ Style: '样式',
+ Transparent: '透明',
+ Uni: '纯色',
+ 'Dégradé linéaire': '线性渐变',
+ 'Dégradé radial': '径向渐变',
+ 'Style d’arrière-plan': '背景样式',
+ Couleur: '颜色',
+ Départ: '起始颜色',
+ Arrivée: '结束颜色',
+ Définition: '分辨率',
+ 'Dimensions inscrites dans le fichier SVG.': '写入 SVG 文件的尺寸。',
+ 'Dimensions du fichier exporté.': '导出文件的尺寸。',
+ 'Définition du Snapshot': '快照分辨率',
+ 'Définition du mode photo': '照片模式分辨率',
+ 'Télécharger le Snapshot SVG': '下载 SVG 快照',
+ 'Télécharger en SVG': '下载 SVG',
+ 'Télécharger en PNG': '下载 PNG',
+ 'Format d’export': '导出格式',
+ 'Format d’export du mode photo': '照片模式导出格式',
+ 'Choisis le type de fichier généré par le mode photo.': '选择照片模式生成的文件类型。',
+ 'Prendre une photo': '拍照',
+ 'Informations sur le mode photo': '照片模式信息',
+ 'Tu peux modifier le format, le fond et la définition du mode photo dans Export.':
+ '你可以在“导出”中更改照片模式的格式、背景和分辨率。',
+ 'Projet du Studio': '工作室项目',
+ 'Transfère tous les avatars, expressions et animations vers un autre navigateur.':
+ '将所有头像、表情和动画传输到另一个浏览器。',
+ 'Télécharger le projet JSON': '下载 JSON 项目',
+ 'Importer un projet JSON': '导入 JSON 项目',
+ 'Ce fichier ne contient pas un projet Avatar Studio valide et compatible.':
+ '此文件不包含有效且兼容的头像工作室项目。',
+ 'Importer ce projet ?': '导入此项目?',
+ 'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.':
+ '当前本地项目将被此文件中的头像、表情、动画和播放状态替换。',
+ 'Le projet n’a pas pu être enregistré dans ce navigateur. Libère de l’espace puis réessaie.':
+ '无法在此浏览器中保存项目。请释放空间后重试。',
+ Importer: '导入',
+ 'Construction, forme et couleur de la tête de l’avatar.': '头像头部的构建、形状和颜色。',
+ 'Une forme principale porte les yeux. Les autres primitives se placent autour d’elle.':
+ '主形状承载眼睛,其他基础形状围绕它放置。',
+ 'Forme principale': '主形状',
+ 'porte les yeux': '承载眼睛',
+ 'Ajouter une forme': '添加形状',
+ Dupliquer: '复制',
+ 'Gizmo local': '局部控制器',
+ 'Déplacer dans le plan de la caméra': '在相机平面中移动',
+ Transformer: '变换',
+ copie: '副本',
+ 'Glisse un axe pour déplacer la forme, ou un anneau pour la faire tourner.':
+ '拖动轴来移动形状,或拖动圆环来旋转形状。',
+ Profondeur: '深度',
+ 'Position locale': '局部位置',
+ 'Cette surface est la référence du visage et porte les yeux.': '此表面是面部基准并承载眼睛。',
+ 'Rondeur des arêtes': '边缘圆度',
+ Rondeur: '圆度',
+ 'Rondeur morphologique': '形态圆度',
+ 'Rondeur de la pointe': '尖端圆度',
+ 'Rondeur de la base': '底部圆度',
+ 'Rondeur globale': '整体圆度',
+ 'Rondeur pointe': '尖端圆度',
+ 'Rondeur base': '底部圆度',
+ 'Couleur de base utilisée par les poses et les expressions.': '姿势和表情使用的基础颜色。',
+ 'Forme, placement, orientation et couleur du regard par défaut.':
+ '默认眼睛的形状、位置、方向和颜色。',
+ 'Définis l’identité du regard de cet avatar. Les poses s’ajoutent ensuite à cette base.':
+ '定义此头像的默认眼睛,姿势将在此基础上叠加。',
+ 'Coordonnées propres à l’avatar, indépendantes des poses.': '头像自身的坐标,与姿势无关。',
+ 'Inclinaison par défaut propre à chaque œil.': '每只眼睛的默认倾斜角度。',
+ 'Orientation et apparence générale de la pose.': '姿势的整体方向和外观。',
+ 'La pose peut remplacer temporairement la couleur de l’avatar.': '姿势可以暂时覆盖头像颜色。',
+ 'Reprendre la couleur de l’avatar': '恢复头像颜色',
+ 'Les libellés ↔ sont scrubbables, comme dans Figma.': '带 ↔ 的标签可像 Figma 一样拖动调整。',
+ 'Forme, placement, orientation et couleur du regard.': '眼睛的形状、位置、方向和颜色。',
+ 'Coordonnées communes projetées sur la forme choisie.': '投影到所选形状上的共享坐标。',
+ 'Inclinaison propre à chaque œil.': '每只眼睛各自的倾斜角度。',
+ 'Perspective et repères appliqués à la surface active.': '应用于当前表面的透视和参考线。',
+ 'Profondeur simulée du visage.': '模拟的面部深度。',
+ 'Afficher le maillage': '显示网格',
+ Réinitialiser: '重置',
+ Mouvement: '运动',
+ Motion: '运动',
+ 'Mouvement perpétuel': '持续运动',
+ 'Aucun mouvement': '无运动',
+ 'Dérive lente': '缓慢漂移',
+ 'Micro-ajustements': '微调',
+ Tremblement: '抖动',
+ 'Ajoute une légère présence ou un tremblement continu au corps.':
+ '为身体添加轻微的生命感或持续抖动。',
+ 'Anime le regard par petites saccades naturelles ou par tremblement.':
+ '通过自然的微小跳动或抖动让眼神动起来。',
+ 'Motion interpole les valeurs et notre moteur effectue le slerp quaternion.':
+ 'Motion 对数值进行插值,引擎执行四元数球面插值。',
+ 'Vitesse du ressort': '弹簧速度',
+ 'Une animation est en cours de lecture': '动画正在播放',
+ 'Mettez l’animation en pause avant de manipuler les paramètres de pose.':
+ '调整姿势参数前请暂停动画。',
+ 'Mettez l’animation en pause avant de choisir ou personnaliser une expression.':
+ '选择或自定义表情前请暂停动画。',
+ Cligner: '眨眼',
+ 'Expression aléatoire': '随机表情',
+ 'Cycle de vie': '生命周期',
+ Réactions: '反应',
+ 'Éditeur d’animation': '动画编辑器',
+ 'Modifier l’animation': '编辑动画',
+ 'Nouvelle animation': '新建动画',
+ 'Compose les expressions, leur cadence et les clignements de cette animation.':
+ '组合此动画的表情、节奏和眨眼。',
+ 'Prévisualiser l’animation': '预览动画',
+ 'Arrêter l’animation': '停止动画',
+ Identité: '基本信息',
+ 'Nom, catégorie et comportement de lecture de l’animation.': '动画的名称、分类和播放行为。',
+ Nom: '名称',
+ Catégorie: '分类',
+ Description: '描述',
+ 'Mode de lecture': '播放模式',
+ Boucle: '循环',
+ 'Une fois': '一次',
+ 'Aller-retour': '往返',
+ Timeline: '时间轴',
+ 'Glisse les étapes pour les réordonner, puis sélectionne-en une pour régler sa cadence.':
+ '拖动步骤以重新排序,然后选择一个步骤调整时间。',
+ 'Ajoute une expression pour commencer.': '添加一个表情以开始。',
+ 'Étape sélectionnée': '已选步骤',
+ 'Durée visible avant de passer à l’expression suivante.': '切换到下一个表情前的显示时长。',
+ 'Supprimer cette étape': '删除此步骤',
+ 'Temps d’affichage': '显示时间',
+ 'Durée de transition': '过渡时长',
+ Transition: '过渡',
+ Ressort: '弹簧',
+ Douce: '平滑',
+ Rapide: '迅速',
+ 'Ajouter une expression': '添加表情',
+ 'Sélectionne un preset pour l’ajouter à la fin de la timeline.':
+ '选择一个预设并添加到时间轴末尾。',
+ Clignements: '眨眼',
+ 'Le blink fonctionne indépendamment des changements d’expression.': '眨眼独立于表情变化运行。',
+ 'Activer les clignements': '启用眨眼',
+ 'Intervalle minimum': '最小间隔',
+ 'Intervalle maximum': '最大间隔',
+ 'Intervalle du clignement': '眨眼间隔',
+ 'Durée du clignement': '眨眼时长',
+ animations: '动画',
+ 'Chaque étape possède sa propre durée et sa propre transition.':
+ '每个步骤都有自己的持续时间和过渡。',
+ Désactivé: '已禁用',
+ 'comportement de la timeline': '时间轴行为',
+ 'Supprimer cette animation ?': '删除此动画?',
+ 'Cette action supprimera définitivement cette animation.': '此操作将永久删除该动画。',
+ 'Cette animation enchaîne un pool de presets et des clignements.':
+ '此动画依次播放一组预设并执行眨眼。',
+ 'Cet état enchaîne un pool de presets et des clignements.': '此动画依次播放一组预设并执行眨眼。',
+ 'Expressions de l’animation': '动画表情',
+ 'Les presets sont joués dans cet ordre, puis la boucle recommence.':
+ '预设按此顺序播放,然后重新开始循环。',
+ 'Logique de clignement': '眨眼行为',
+ 'Le rythme reste naturel grâce à un intervalle légèrement aléatoire.':
+ '略带随机性的间隔使节奏保持自然。',
+ 'Premier clignement': '首次眨眼',
+ 'après le lancement': '启动后',
+ Intervalle: '间隔',
+ 'tirage aléatoire': '随机',
+ Durée: '时长',
+ 'fermeture et ouverture': '闭合与睁开',
+ 'Changement d’expression': '表情变化',
+ 'cadence de l’animation': '动画节奏',
+ 'Détails de l’animation': '动画详情',
+ 'Afficher les détails de l’animation': '显示动画详情',
+ 'Masquer les détails de l’animation': '隐藏动画详情',
+ Relancer: '重新开始',
+ Lancer: '播放',
+ Pause: '暂停',
+ Reprendre: '继续',
+ Annuler: '取消',
+ 'Supprimer cette expression ?': '删除此表情?',
+ 'Cette action retirera définitivement le preset de la bibliothèque de cet avatar.':
+ '此操作将从该头像的库中永久删除该预设。',
+ 'Cette expression sera aussi retirée des animations suivantes :': '此表情也会从以下动画中移除:',
+ 'Si une animation ne contient que cette expression, l’expression de repli lui sera assignée pour qu’elle reste jouable.':
+ '如果动画只包含此表情,将为其分配备用表情以保持可播放。',
+ 'Le corps, les expressions et les animations propres à cet avatar seront définitivement supprimés. La bibliothèque de base sera conservée.':
+ '此头像的身体、表情和动画将被永久删除,基础库将被保留。',
+ 'Glisse sur la surface pour orienter la tête. Les anneaux du gizmo contrôlent X, Y et Z.':
+ '在表面上拖动以调整头部方向。控制器圆环分别控制 X、Y 和 Z。',
+ Sphère: '球体',
+ Cube: '立方体',
+ Capsule: '胶囊体',
+ Curseur: '光标',
+ Cylindre: '圆柱体',
+ Cône: '圆锥体',
+ Diamant: '菱形体',
+ 'Yeux presque fermés, respiration lente et expression de sommeil.':
+ '双眼几乎闭合,呼吸缓慢,呈现睡眠表情。',
+ 'Animation courte de réveil avant retour vers une expression neutre.':
+ '短暂的醒来动画,然后恢复中性表情。',
+ 'Séquence courte de réveil avant retour vers une expression neutre.':
+ '短暂的醒来动画,然后恢复中性表情。',
+ 'Micro-mouvements lents, expressions 00 et 08, clignement rare.':
+ '缓慢微动,使用表情 00 和 08,偶尔眨眼。',
+ 'Expressions 10, 01 et 19, regard stable et clignement attentif.':
+ '使用表情 10、01 和 19,视线稳定并专注眨眼。',
+ 'Regard haut et latéral, expressions asymétriques et changements fréquents.':
+ '向上侧视,不对称表情并频繁变化。',
+ 'Balayage rapide et changements très fréquents.': '快速扫视并非常频繁地变化。',
+ 'Rythme régulier, regard concentré et micro-variations.': '节奏稳定,目光专注并有细微变化。',
+ 'Grandes expressions positives et mouvements rapides.': '夸张的积极表情和快速运动。',
+ 'Inclinaisons et rotations rapides pour simuler un agent en mouvement.':
+ '通过快速倾斜和旋转模拟运动中的角色。',
+ 'Rythme régulier et expressions concentrées.': '节奏稳定且表情专注。',
+ 'Grandes expressions et transitions rapides.': '夸张表情和快速过渡。',
+ 'Inclinaisons et forte asymétrie.': '明显倾斜和强烈不对称。',
+ loop: '循环',
+ once: '一次',
+ pingPong: '往返',
+ Custom: '自定义',
+ 'Untitled animation': '未命名动画',
+ 'Untitled sequence': '未命名动画',
+ sleeping: '睡眠',
+ waking: '醒来',
+ idle: '待机',
+ listening: '聆听',
+ thinking: '思考',
+ searching: '搜索',
+ working: '工作',
+ excited: '兴奋',
+ surprised: '惊讶',
+ suspicious: '怀疑',
+ angry: '生气',
+ drowsy: '困倦',
+ happy: '开心',
+ curious: '好奇',
+ confused: '困惑',
+ bored: '无聊',
+ proud: '自豪',
+ shy: '害羞',
+ sad: '悲伤',
+ laughing: '大笑',
+ scared: '害怕',
+ playful: '顽皮',
+ celebrate: '庆祝',
+ orbit: '环绕',
+ radar: '雷达',
+ progress: '进度',
+ spawning: '出现',
+ humming: '哼唱',
+ loading: '加载',
+ dictating: '听写',
+ writing: '书写',
+ sending: '发送',
+ receiving: '接收',
+ uploading: '上传',
+ notifying: '通知',
+ alerting: '警报',
+ dragging: '拖动',
+ bouncing: '弹跳',
+ 'powering-down': '关机',
+ Avatars: '头像',
+ 'React / TypeScript': 'React / TypeScript',
+}
+
+export const translateChineseDynamicText = (text: string) => {
+ const rules: Array<[RegExp, (...matches: string[]) => string]> = [
+ [/^Modifier l’expression (.+)$/, value => `编辑表情 ${value}`],
+ [/^Modifier (.+)$/, value => `编辑${value}`],
+ [/^Supprimer (.+) \?$/, value => `删除${value}?`],
+ [/^Ajouter une forme · (.+)$/, value => `添加形状 · ${value}`],
+ [/^Réinitialiser la position et la rotation de (.+)$/, value => `重置${value}的位置和旋转`],
+ [/^Animation en cours : (.+)$/, value => `当前动画:${value}`],
+ [/^Mettre (.+) en pause$/, value => `暂停${value}`],
+ [/^Reprendre (.+)$/, value => `继续${value}`],
+ [/^Arrêter (.+)$/, value => `停止${value}`],
+ [/^(.+) expressions$/, value => `${value} 个表情`],
+ ]
+
+ for (const [pattern, replacement] of rules) {
+ const match = text.match(pattern)
+ if (match) return replacement(...match.slice(1))
+ }
+ return null
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..d1d7f94
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,4 @@
+import { clsx, type ClassValue } from 'clsx'
+import { twMerge } from 'tailwind-merge'
+
+export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs))
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..5bae583
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,11 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+
+import App from './App'
+import './styles.css'
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+)
diff --git a/src/manipulationSession.ts b/src/manipulationSession.ts
new file mode 100644
index 0000000..978af20
--- /dev/null
+++ b/src/manipulationSession.ts
@@ -0,0 +1,35 @@
+export type ManipulationSession = {
+ initial: Value
+ latest: Value
+}
+
+export const beginManipulation = (initial: Value): ManipulationSession => ({
+ initial,
+ latest: initial,
+})
+
+export const beginManipulationFromRenderedValue = (
+ readRenderedValue: () => Value
+): ManipulationSession => beginManipulation(readRenderedValue())
+
+export const previewManipulation = (
+ session: ManipulationSession,
+ next: Value,
+ preview?: (value: Value) => void
+) => {
+ session.latest = next
+ preview?.(next)
+}
+
+export const finishManipulation = (
+ session: ManipulationSession,
+ outcome: 'commit' | 'cancel',
+ handlers: { preview: (value: Value) => void; commit: (value: Value) => void }
+) => {
+ if (outcome === 'cancel') {
+ handlers.preview(session.initial)
+ return session.initial
+ }
+ handlers.commit(session.latest)
+ return session.latest
+}
diff --git a/src/playback.ts b/src/playback.ts
new file mode 100644
index 0000000..652dc79
--- /dev/null
+++ b/src/playback.ts
@@ -0,0 +1,68 @@
+import { advanceSequenceCursor, type AvatarSequence } from './sequences'
+
+export type PlaybackTimeline = {
+ position: number
+ direction: 1 | -1
+ stepDueAt: number | null
+ stepRemainingMs: number
+ blinkDueAt: number | null
+ blinkRemainingMs: number
+}
+
+export const createPlaybackTimeline = (): PlaybackTimeline => ({
+ position: 0,
+ direction: 1,
+ stepDueAt: null,
+ stepRemainingMs: 0,
+ blinkDueAt: null,
+ blinkRemainingMs: 0,
+})
+
+export const beginPlayback = (timeline: PlaybackTimeline, preservePosition = false) => {
+ return {
+ ...timeline,
+ position: preservePosition ? timeline.position : 0,
+ direction: preservePosition ? timeline.direction : 1,
+ stepDueAt: null,
+ blinkDueAt: null,
+ } satisfies PlaybackTimeline
+}
+
+export const schedulePlaybackStep = (timeline: PlaybackTimeline, now: number, delayMs: number) => {
+ return { ...timeline, stepRemainingMs: delayMs, stepDueAt: now + delayMs }
+}
+
+export const schedulePlaybackBlink = (timeline: PlaybackTimeline, now: number, delayMs: number) => {
+ return { ...timeline, blinkRemainingMs: delayMs, blinkDueAt: now + delayMs }
+}
+
+export const pausePlaybackTimeline = (timeline: PlaybackTimeline, now: number) => {
+ return {
+ ...timeline,
+ stepRemainingMs:
+ timeline.stepDueAt === null
+ ? timeline.stepRemainingMs
+ : Math.max(timeline.stepDueAt - now, 0),
+ blinkRemainingMs:
+ timeline.blinkDueAt === null
+ ? timeline.blinkRemainingMs
+ : Math.max(timeline.blinkDueAt - now, 0),
+ stepDueAt: null,
+ blinkDueAt: null,
+ }
+}
+
+export const advancePlaybackTimeline = (timeline: PlaybackTimeline, sequence: AvatarSequence) => {
+ const cursor = advanceSequenceCursor(sequence, timeline.position, timeline.direction)
+ return {
+ cursor,
+ timeline: {
+ ...timeline,
+ position: cursor.index,
+ direction: cursor.direction,
+ stepDueAt: null,
+ },
+ }
+}
+
+export const stopPlaybackTimeline = (_timeline: PlaybackTimeline) => createPlaybackTimeline()
diff --git a/src/presets.ts b/src/presets.ts
new file mode 100644
index 0000000..3b3a416
--- /dev/null
+++ b/src/presets.ts
@@ -0,0 +1,219 @@
+import type { Expression } from './geometry'
+
+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],
+ [-36.2, 13.1, 15.5, 44.3, 51.3, 74.2, 76, 68.7, -40.7, 0, 0],
+ [15.6, -16.5, -11.3, 54, 51, 49.6, 48.5, 70.9, 30.1, 0, 0],
+ [3.4, 13, 8.9, 42.6, 44, 17.3, 16, 57.9, 4.9, 0, 0],
+ [-17.7, -1.4, -8.8, 29.5, 19.2, 51.6, 41.9, 56.3, 0, 0, 90],
+ [14.8, 14.5, 5.5, 22.9, 22.2, 32.4, 33.4, 50.9, 39.2, 0, 0],
+ [25.7, 16.5, -13.5, 48.5, 48.3, 33.5, 33, 53.3, 41.3, 61.3, -80],
+ [-22.8, -15.9, 6.2, 44.5, 43.9, 32.3, 24.6, 54.9, -42, -60.9, 69.2],
+ [-11.6, 8.3, -12.7, 42.5, 22.1, 41.8, 22.2, 61.7, 12.3, 0, 0],
+ [20.3, 7, 8.7, 30.2, 28.1, 48.8, 49.2, 56.8, 39.9, 0, 0],
+ [17.5, -15.2, -8.7, 51, 49.2, 75.3, 73.4, 70.2, 41.6, 0, 0],
+ [-10.4, 15.2, 11.8, 50.6, 51.6, 50, 50.7, 69.5, 16.7, 0, 0],
+ [-6, -7.7, -9.4, 43.6, 42.8, 15.5, 18.1, 57.9, 3.5, 0, 0],
+ [0.2, -3.1, 9, 29.6, 16.8, 51.5, 41.3, 56.4, -7.8, 0, 90],
+ [-16.2, 38.4, 2.4, 23.7, 26.2, 32.7, 34.6, 53.9, -41.1, 0, 0],
+ [3.5, -16.1, 15.8, 51, 48.5, 34.9, 33, 55.1, 41.9, 80, -62.2],
+ [-17.3, 11.2, -9.1, 24.2, 44.5, 44.5, 32.2, 55, -36.5, 18.5, 67.9],
+ [-0.7, 3.6, 12.2, 42.1, 22.2, 41.7, 22.1, 60.4, -9.1, 0, 0],
+ [-25.3, -12.4, -13.3, 30.5, 26.8, 49.9, 48.8, 56.2, -35.8, 0, 0],
+ [-41.1, 20.2, 18.8, 44.6, 53, 74.9, 77.8, 70.8, -40.6, 0, 0],
+ [-14.6, -12.5, -16.1, 51.4, 50.5, 50.1, 49.4, 69, -20, 0, 0],
+ [10, 2.7, 8.8, 42.9, 43.3, 16.4, 17.8, 57.9, 2.7, 0, 0],
+ [-17.8, 10, -6.3, 28.8, 17.3, 51.4, 42.7, 56.6, -9.8, 0, 90],
+ [-29.6, 7.5, 10.1, 21.5, 23.2, 32, 33.5, 51.2, -37.4, 0, 0],
+]
+
+export const initialExpressions: Expression[] = calibrated.map(
+ (
+ [
+ headX,
+ headY,
+ headZ,
+ widthLeft,
+ widthRight,
+ heightLeft,
+ heightRight,
+ spacing,
+ latitude,
+ leftAngle,
+ rightAngle,
+ ],
+ index
+ ) => ({
+ id: `expression-${String(index).padStart(2, '0')}`,
+ headX,
+ headY,
+ headZ,
+ widthLeft,
+ widthRight,
+ heightLeft,
+ heightRight,
+ spacing,
+ positionXLeft: 0,
+ positionXRight: 0,
+ positionYLeft: latitude,
+ positionYRight: latitude,
+ leftAngle,
+ rightAngle,
+ perspective: 1,
+ eyeMotion: 'none',
+ bodyMotion: 'none',
+ })
+)
+
+export const defaultExpression: Expression = {
+ id: 'expression-neutral',
+ headX: 0,
+ headY: 0,
+ headZ: 0,
+ widthLeft: 20,
+ widthRight: 20,
+ heightLeft: 50,
+ heightRight: 50,
+ spacing: 35,
+ positionXLeft: 0,
+ positionXRight: 0,
+ positionYLeft: -7,
+ positionYRight: -7,
+ leftAngle: 0,
+ rightAngle: 0,
+ perspective: 1,
+ eyeMotion: 'none',
+ bodyMotion: 'none',
+}
+
+export const stateGroups = {
+ 'Cycle de vie': ['sleeping', 'waking', 'idle', 'listening', 'thinking', 'searching', 'working'],
+ Réactions: [
+ 'excited',
+ 'surprised',
+ 'suspicious',
+ 'angry',
+ 'drowsy',
+ 'happy',
+ 'curious',
+ 'confused',
+ 'bored',
+ 'proud',
+ 'shy',
+ 'sad',
+ 'laughing',
+ 'scared',
+ 'playful',
+ 'celebrate',
+ ],
+} as const
+
+export const statePools: Record = {
+ sleeping: [13, 22, 4],
+ waking: [13],
+ idle: [0, 8],
+ listening: [10, 1, 19],
+ thinking: [8, 16, 14, 17, 5],
+ searching: [15, 9, 3, 20, 12, 18],
+ working: [7, 16, 11, 10],
+ excited: [2, 17, 21, 3, 11],
+ surprised: [3, 21],
+ suspicious: [14, 5, 23],
+ angry: [7, 16],
+ drowsy: [4, 22, 13],
+ happy: [2, 11, 17, 19],
+ curious: [3, 21, 0, 15],
+ confused: [14, 5, 8],
+ bored: [4, 22, 0],
+ proud: [15, 8, 2],
+ shy: [0, 24, 13],
+ sad: [4, 13, 22],
+ laughing: [2, 11, 17],
+ scared: [3, 21],
+ playful: [2, 17, 11, 8],
+ celebrate: [2, 8, 17],
+}
+
+export type StatePlaybackConfig = {
+ expressionIntervalMs: number
+ blink: {
+ initialDelayMs: number
+ minIntervalMs: number
+ maxIntervalMs: number
+ durationMs: number
+ }
+}
+
+const blinkProfiles = {
+ natural: {
+ initialDelayMs: 2600,
+ minIntervalMs: 3400,
+ maxIntervalMs: 6200,
+ durationMs: 280,
+ },
+ calm: {
+ initialDelayMs: 4800,
+ minIntervalMs: 6500,
+ maxIntervalMs: 9500,
+ durationMs: 420,
+ },
+ attentive: {
+ initialDelayMs: 3200,
+ minIntervalMs: 4800,
+ maxIntervalMs: 7200,
+ durationMs: 240,
+ },
+ active: {
+ initialDelayMs: 2100,
+ minIntervalMs: 2800,
+ maxIntervalMs: 5000,
+ durationMs: 260,
+ },
+ reactive: {
+ initialDelayMs: 1200,
+ minIntervalMs: 1800,
+ maxIntervalMs: 3600,
+ durationMs: 220,
+ },
+} as const
+
+const calmStates = new Set(['sleeping', 'drowsy', 'bored', 'sad'])
+const attentiveStates = new Set(['listening'])
+const reactiveStates = new Set([
+ 'waking',
+ 'excited',
+ 'surprised',
+ 'laughing',
+ 'scared',
+ 'celebrate',
+])
+
+export const getStatePlaybackConfig = (name: string): StatePlaybackConfig => {
+ const blink =
+ name === 'idle'
+ ? blinkProfiles.natural
+ : calmStates.has(name)
+ ? blinkProfiles.calm
+ : attentiveStates.has(name)
+ ? blinkProfiles.attentive
+ : reactiveStates.has(name)
+ ? blinkProfiles.reactive
+ : blinkProfiles.active
+ return {
+ expressionIntervalMs: name === 'idle' ? 5200 : calmStates.has(name) ? 3600 : 2300,
+ blink: { ...blink },
+ }
+}
+
+export const stateNotes: Record = {
+ sleeping: 'Yeux presque fermés, respiration lente et expression de sommeil.',
+ waking: 'Animation courte de réveil avant retour vers une expression neutre.',
+ idle: 'Micro-mouvements lents, expressions 00 et 08, clignement rare.',
+ listening: 'Expressions 10, 01 et 19, regard stable et clignement attentif.',
+ thinking: 'Regard haut et latéral, expressions asymétriques et changements fréquents.',
+ searching: 'Balayage rapide et changements très fréquents.',
+ working: 'Rythme régulier et expressions concentrées.',
+ excited: 'Grandes expressions et transitions rapides.',
+ curious: 'Inclinaisons et forte asymétrie.',
+}
diff --git a/src/proceduralBrowserRuntime.ts b/src/proceduralBrowserRuntime.ts
new file mode 100644
index 0000000..2d88c4d
--- /dev/null
+++ b/src/proceduralBrowserRuntime.ts
@@ -0,0 +1,351 @@
+export const proceduralBrowserRuntime = `
+const SVG_NS = 'http://www.w3.org/2000/svg';
+const avatarInstanceId = () => typeof globalThis.crypto?.randomUUID === 'function'
+ ? globalThis.crypto.randomUUID()
+ : Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
+const clamp01 = value => Math.max(0, Math.min(1, value));
+const easeProgress = (progress, transition) => transition === 'smooth'
+ ? progress * progress * (3 - 2 * progress)
+ : transition === 'snappy'
+ ? 1 - (1 - progress) ** 3
+ : 1 - Math.exp(-6 * progress) * Math.cos(8 * progress);
+const nearestAngle = (target, current) => {
+ let resolved = target;
+ while (resolved - current > 180) resolved -= 360;
+ while (resolved - current < -180) resolved += 360;
+ return resolved;
+};
+const resolvedTargetExpression = (target, current) => ({
+ ...target,
+ headX: nearestAngle(target.headX, current.headX),
+ headY: nearestAngle(target.headY, current.headY),
+ headZ: nearestAngle(target.headZ, current.headZ),
+ leftAngle: nearestAngle(target.leftAngle, current.leftAngle),
+ rightAngle: nearestAngle(target.rightAngle, current.rightAngle),
+});
+const colorChannels = color => {
+ const value = color.replace('#', '');
+ const hex = value.length === 3 ? value.split('').map(channel => channel + channel).join('') : value;
+ const numeric = Number.parseInt(hex, 16);
+ return [(numeric >> 16) & 255, (numeric >> 8) & 255, numeric & 255];
+};
+const interpolateColor = (from, to, progress) => {
+ const left = colorChannels(from);
+ const right = colorChannels(to);
+ const value = left.map((channel, index) => Math.round(channel + (right[index] - channel) * progress));
+ return '#' + value.map(channel => channel.toString(16).padStart(2, '0')).join('');
+};
+const resolveColors = expression => ({
+ body: expression.bodyColor || DATA.avatar.colors.body,
+ eyes: expression.eyeColor || DATA.avatar.colors.eyes,
+});
+const svgElement = name => document.createElementNS(SVG_NS, name);
+
+function mountAvatar(target, options = {}) {
+ const host = typeof target === 'string' ? document.querySelector(target) : target;
+ if (!host) throw new Error('Avatar target was not found.');
+ const animationNames = Object.keys(DATA.animations);
+ if (!animationNames.length) throw new Error('The avatar export contains no animations.');
+ const instanceId = avatarInstanceId();
+ const clipId = 'avatar-procedural-clip-' + instanceId;
+ const svg = svgElement('svg');
+ svg.setAttribute('viewBox', '-150 -150 300 300');
+ svg.setAttribute('role', 'img');
+ svg.setAttribute('aria-label', DATA.avatar.name);
+ svg.style.width = typeof options.size === 'number' ? options.size + 'px' : options.size || '100%';
+ svg.style.height = typeof options.size === 'number' ? options.size + 'px' : options.size || '100%';
+ svg.style.display = 'block';
+ svg.style.overflow = 'visible';
+ const defs = svgElement('defs');
+ const clipPath = svgElement('clipPath');
+ const clipHead = svgElement('path');
+ clipPath.id = clipId;
+ clipPath.append(clipHead);
+ defs.append(clipPath);
+ svg.append(defs);
+ const motionLayer = svgElement('g');
+ const backLayer = svgElement('g');
+ const head = svgElement('path');
+ const eyesLayer = svgElement('g');
+ const leftEye = svgElement('path');
+ const rightEye = svgElement('path');
+ const frontLayer = svgElement('g');
+ eyesLayer.setAttribute('clip-path', 'url(#' + clipId + ')');
+ eyesLayer.append(leftEye, rightEye);
+ motionLayer.append(backLayer, head, eyesLayer, frontLayer);
+ svg.append(motionLayer);
+ host.replaceChildren(svg);
+
+ const ensurePaths = (group, paths, fill) => {
+ while (group.children.length < paths.length) group.append(svgElement('path'));
+ while (group.children.length > paths.length) group.lastElementChild.remove();
+ paths.forEach((path, index) => {
+ group.children[index].setAttribute('d', path);
+ group.children[index].setAttribute('fill', fill);
+ });
+ };
+ let currentAnimation = options.animation && DATA.animations[options.animation] ? options.animation : animationNames[0];
+ const initialStep = DATA.animations[currentAnimation].steps[0];
+ const initialExpression = DATA.expressions[initialStep.expressionId];
+ let currentPose = AvatarProceduralEngine.poseFromExpression(initialExpression);
+ let currentColors = resolveColors(initialExpression);
+ let blinkAmount = 1;
+ let transitionState = null;
+ let blinkState = null;
+ let frameRequest = null;
+ let stepTimer = null;
+ let blinkTimer = null;
+ let blinkDueAt = null;
+ let stepIndex = 0;
+ let direction = 1;
+ let playing = false;
+ let paused = false;
+ let pausedRemainingMs = 0;
+ let pausedTransition = null;
+ let pausedBlink = null;
+ let pausedBlinkDelay = 0;
+ let stepDueAt = null;
+ let ambientStartedAt = performance.now();
+ let ambientSignature = initialExpression.eyeMotion + ':' + initialExpression.bodyMotion;
+ let lastAmbientFrame = 0;
+
+ const applyMotion = expression => {
+ const signature = expression.eyeMotion + ':' + expression.bodyMotion;
+ if (signature !== ambientSignature) {
+ ambientSignature = signature;
+ ambientStartedAt = performance.now();
+ }
+ };
+ const render = (time = performance.now()) => {
+ const elapsed = time - ambientStartedAt;
+ const expression = AvatarProceduralEngine.hasAmbientMotion(currentPose.expression)
+ ? AvatarProceduralEngine.applyAmbientMotion(currentPose.expression, elapsed)
+ : currentPose.expression;
+ const renderedPose = AvatarProceduralEngine.poseFromExpression(expression);
+ const geometry = AvatarProceduralEngine.renderAvatar(renderedPose, DATA.avatar.surface, blinkAmount, {
+ includeWire: false,
+ bodyNodes: DATA.avatar.bodyNodes,
+ });
+ const offset = AvatarProceduralEngine.ambientBodyOffset(currentPose.expression, elapsed);
+ motionLayer.setAttribute('transform', 'translate(' + offset.x + ' ' + offset.y + ')');
+ ensurePaths(backLayer, geometry.backPaths, currentColors.body);
+ ensurePaths(frontLayer, geometry.frontPaths, currentColors.body);
+ head.setAttribute('d', geometry.headPath);
+ head.setAttribute('fill', currentColors.body);
+ clipHead.setAttribute('d', geometry.headPath);
+ leftEye.setAttribute('d', geometry.leftPath);
+ rightEye.setAttribute('d', geometry.rightPath);
+ leftEye.setAttribute('fill', currentColors.eyes);
+ rightEye.setAttribute('fill', currentColors.eyes);
+ leftEye.style.display = geometry.leftVisible ? '' : 'none';
+ rightEye.style.display = geometry.rightVisible ? '' : 'none';
+ };
+ const tick = time => {
+ frameRequest = null;
+ if (transitionState) {
+ const linear = clamp01((time - transitionState.startedAt) / transitionState.durationMs);
+ const eased = easeProgress(linear, transitionState.transition);
+ const expression = { ...transitionState.fromPose.expression };
+ AvatarProceduralEngine.expressionFields.forEach(field => {
+ expression[field] = transitionState.fromPose.expression[field] +
+ (transitionState.toPose.expression[field] - transitionState.fromPose.expression[field]) * eased;
+ });
+ expression.eyeMotion = transitionState.toPose.expression.eyeMotion;
+ expression.bodyMotion = transitionState.toPose.expression.bodyMotion;
+ currentPose = AvatarProceduralEngine.poseFromExpression(expression);
+ currentColors = {
+ body: interpolateColor(transitionState.fromColors.body, transitionState.toColors.body, clamp01(eased)),
+ eyes: interpolateColor(transitionState.fromColors.eyes, transitionState.toColors.eyes, clamp01(eased)),
+ };
+ if (linear >= 1) {
+ currentPose = transitionState.toPose;
+ currentColors = transitionState.toColors;
+ transitionState = null;
+ }
+ }
+ if (blinkState) {
+ const progress = clamp01((time - blinkState.startedAt) / blinkState.durationMs);
+ if (progress <= 0.42) {
+ const closeProgress = progress / 0.42;
+ blinkAmount = 1 - closeProgress * closeProgress;
+ } else {
+ const openProgress = (progress - 0.42) / 0.58;
+ blinkAmount = 1 - (1 - openProgress) ** 2;
+ }
+ if (progress >= 1) {
+ blinkAmount = 1;
+ blinkState = null;
+ }
+ }
+ const ambientActive = AvatarProceduralEngine.hasAmbientMotion(currentPose.expression);
+ if (transitionState || blinkState || !ambientActive || time - lastAmbientFrame >= 1000 / 30) {
+ render(time);
+ if (ambientActive) lastAmbientFrame = time;
+ }
+ if (transitionState || blinkState || ambientActive) frameRequest = requestAnimationFrame(tick);
+ };
+ const requestTick = () => {
+ if (frameRequest === null) frameRequest = requestAnimationFrame(tick);
+ };
+ const animateTo = (expressionId, durationMs, transition) => {
+ const target = DATA.expressions[expressionId];
+ if (!target) return;
+ applyMotion(target);
+ const resolved = resolvedTargetExpression(target, currentPose.expression);
+ const targetPose = AvatarProceduralEngine.poseFromExpression(resolved);
+ const targetColors = resolveColors(target);
+ if (durationMs <= 0) {
+ transitionState = null;
+ currentPose = targetPose;
+ currentColors = targetColors;
+ render();
+ if (AvatarProceduralEngine.hasAmbientMotion(currentPose.expression)) requestTick();
+ return;
+ }
+ transitionState = {
+ fromPose: currentPose,
+ toPose: targetPose,
+ fromColors: currentColors,
+ toColors: targetColors,
+ startedAt: performance.now(),
+ durationMs,
+ transition,
+ expressionId,
+ };
+ requestTick();
+ };
+ const clearSchedule = () => {
+ if (stepTimer !== null) clearTimeout(stepTimer);
+ if (blinkTimer !== null) clearTimeout(blinkTimer);
+ stepTimer = null;
+ blinkTimer = null;
+ blinkDueAt = null;
+ stepDueAt = null;
+ };
+ const scheduleBlink = (animation, delay) => {
+ if (!animation.blink.enabled) return;
+ blinkDueAt = performance.now() + delay;
+ blinkTimer = setTimeout(() => {
+ blinkDueAt = null;
+ blinkState = { startedAt: performance.now(), durationMs: animation.blink.durationMs };
+ requestTick();
+ const range = animation.blink.maxIntervalMs - animation.blink.minIntervalMs;
+ scheduleBlink(animation, animation.blink.durationMs + animation.blink.minIntervalMs + Math.random() * range);
+ }, delay);
+ };
+ const advance = animation => {
+ const last = animation.steps.length - 1;
+ const playbackMode = options.loop === true ? 'loop' : options.loop === false ? 'once' : animation.playbackMode;
+ if (playbackMode === 'once' && stepIndex >= last) {
+ playing = false;
+ options.onAnimationEnd?.(currentAnimation);
+ return;
+ }
+ if (playbackMode === 'pingPong' && last > 0) {
+ if (stepIndex >= last) direction = -1;
+ else if (stepIndex <= 0) direction = 1;
+ stepIndex += direction;
+ } else stepIndex = (stepIndex + 1) % (last + 1);
+ runStep(animation);
+ };
+ const runStep = animation => {
+ if (!playing || !animation.steps.length) return;
+ const step = animation.steps[stepIndex];
+ animateTo(step.expressionId, step.transitionMs, step.transition);
+ const duration = step.transitionMs + step.holdMs;
+ stepDueAt = performance.now() + duration;
+ stepTimer = setTimeout(() => advance(animation), duration);
+ };
+ const api = {
+ element: svg,
+ get animation() { return currentAnimation; },
+ get playing() { return playing; },
+ play(animationName) {
+ animationName = animationName || currentAnimation;
+ if (!DATA.animations[animationName]) throw new Error('Unknown animation: ' + animationName);
+ clearSchedule();
+ if (animationName === currentAnimation && paused) {
+ paused = false;
+ playing = true;
+ if (pausedTransition) animateTo(pausedTransition.expressionId, pausedTransition.durationMs, pausedTransition.transition);
+ if (pausedBlink) {
+ blinkState = {
+ startedAt: performance.now() - pausedBlink.progress * pausedBlink.durationMs,
+ durationMs: pausedBlink.durationMs,
+ };
+ requestTick();
+ }
+ stepDueAt = performance.now() + pausedRemainingMs;
+ stepTimer = setTimeout(() => advance(DATA.animations[currentAnimation]), pausedRemainingMs);
+ scheduleBlink(
+ DATA.animations[currentAnimation],
+ pausedBlinkDelay || DATA.animations[currentAnimation].blink.minIntervalMs
+ );
+ pausedTransition = null;
+ pausedBlink = null;
+ pausedBlinkDelay = 0;
+ return api;
+ }
+ currentAnimation = animationName;
+ stepIndex = 0;
+ direction = 1;
+ paused = false;
+ playing = true;
+ runStep(DATA.animations[currentAnimation]);
+ scheduleBlink(DATA.animations[currentAnimation], DATA.animations[currentAnimation].blink.initialDelayMs);
+ return api;
+ },
+ pause() {
+ const now = performance.now();
+ if (playing && stepDueAt !== null) pausedRemainingMs = Math.max(stepDueAt - now, 0);
+ pausedBlinkDelay = blinkDueAt === null ? 0 : Math.max(blinkDueAt - now, 0);
+ if (transitionState) {
+ const elapsed = now - transitionState.startedAt;
+ pausedTransition = {
+ expressionId: transitionState.expressionId,
+ durationMs: Math.max(transitionState.durationMs - elapsed, 0),
+ transition: transitionState.transition,
+ };
+ }
+ if (blinkState) {
+ pausedBlink = {
+ progress: clamp01((now - blinkState.startedAt) / blinkState.durationMs),
+ durationMs: blinkState.durationMs,
+ };
+ }
+ clearSchedule();
+ transitionState = null;
+ blinkState = null;
+ paused = true;
+ playing = false;
+ render();
+ return api;
+ },
+ stop() {
+ clearSchedule();
+ transitionState = null;
+ blinkState = null;
+ blinkAmount = 1;
+ pausedBlink = null;
+ pausedBlinkDelay = 0;
+ paused = false;
+ playing = false;
+ stepIndex = 0;
+ direction = 1;
+ const first = DATA.animations[currentAnimation].steps[0];
+ if (first) animateTo(first.expressionId, 0, first.transition);
+ return api;
+ },
+ destroy() {
+ clearSchedule();
+ if (frameRequest !== null) cancelAnimationFrame(frameRequest);
+ svg.remove();
+ },
+ };
+ applyMotion(initialExpression);
+ render();
+ if (AvatarProceduralEngine.hasAmbientMotion(initialExpression)) requestTick();
+ if (options.autoplay !== false) api.play(currentAnimation);
+ return api;
+}
+`
diff --git a/src/renderedRotationGizmo.ts b/src/renderedRotationGizmo.ts
new file mode 100644
index 0000000..ab5cf32
--- /dev/null
+++ b/src/renderedRotationGizmo.ts
@@ -0,0 +1,43 @@
+import { motionValue, type MotionValue } from 'motion'
+
+import { poseFromExpression, rotationRing, type Expression, type Point3 } from './geometry'
+
+export type RenderedRotationGizmo = {
+ xPath: MotionValue
+ yPath: MotionValue
+ zPath: MotionValue
+}
+
+export const rotationRingPath = (points: Point3[]) =>
+ `M${points[0][0]} ${points[0][1]}${points
+ .slice(1)
+ .map(point => `L${point[0]} ${point[1]}`)
+ .join('')}Z`
+
+const rotationPaths = (expression: Expression) => {
+ const pose = poseFromExpression(expression)
+ return {
+ x: rotationRingPath(rotationRing(pose, 'x')),
+ y: rotationRingPath(rotationRing(pose, 'y')),
+ z: rotationRingPath(rotationRing(pose, 'z')),
+ }
+}
+
+export const createRenderedRotationGizmo = (expression: Expression): RenderedRotationGizmo => {
+ const paths = rotationPaths(expression)
+ return {
+ xPath: motionValue(paths.x),
+ yPath: motionValue(paths.y),
+ zPath: motionValue(paths.z),
+ }
+}
+
+export const paintRenderedRotationGizmo = (
+ gizmo: RenderedRotationGizmo,
+ expression: Expression
+) => {
+ const paths = rotationPaths(expression)
+ gizmo.xPath.jump(paths.x)
+ gizmo.yPath.jump(paths.y)
+ gizmo.zPath.jump(paths.z)
+}
diff --git a/src/renderedScene.ts b/src/renderedScene.ts
new file mode 100644
index 0000000..952b929
--- /dev/null
+++ b/src/renderedScene.ts
@@ -0,0 +1,82 @@
+import { motionValue, type MotionValue } from 'motion'
+
+import type { AvatarColors } from './avatars'
+import { MAX_BODY_NODES } from './body'
+import type { AvatarGeometry } from './geometry'
+
+export type RenderedScene = {
+ headPath: MotionValue
+ backPaths: MotionValue[]
+ frontPaths: MotionValue[]
+ backNodeIds: { current: (string | null)[] }
+ frontNodeIds: { current: (string | null)[] }
+ leftPath: MotionValue
+ rightPath: MotionValue
+ leftOpacity: MotionValue
+ rightOpacity: MotionValue
+ offsetX: MotionValue
+ offsetY: MotionValue
+ wirePaths: MotionValue[]
+}
+
+export type RenderedColors = {
+ body: MotionValue
+ eyes: MotionValue
+}
+
+const bodyPathSlots = MAX_BODY_NODES + 2
+
+export const createRenderedScene = (geometry: AvatarGeometry): RenderedScene => ({
+ headPath: motionValue(geometry.headPath),
+ backPaths: Array.from({ length: bodyPathSlots }, (_, index) =>
+ motionValue(geometry.backPaths[index] ?? '')
+ ),
+ frontPaths: Array.from({ length: bodyPathSlots }, (_, index) =>
+ motionValue(geometry.frontPaths[index] ?? '')
+ ),
+ backNodeIds: { current: geometry.backNodeIds },
+ frontNodeIds: { current: geometry.frontNodeIds },
+ leftPath: motionValue(geometry.leftPath),
+ rightPath: motionValue(geometry.rightPath),
+ leftOpacity: motionValue(geometry.leftVisible ? 1 : 0),
+ rightOpacity: motionValue(geometry.rightVisible ? 1 : 0),
+ offsetX: motionValue(0),
+ offsetY: motionValue(0),
+ wirePaths: geometry.wirePaths.map(path => motionValue(path)),
+})
+
+export const createRenderedColors = (colors: AvatarColors): RenderedColors => ({
+ body: motionValue(colors.body),
+ eyes: motionValue(colors.eyes),
+})
+
+export const paintRenderedColors = (rendered: RenderedColors, colors: AvatarColors) => {
+ rendered.body.set(colors.body)
+ rendered.eyes.set(colors.eyes)
+}
+
+export const paintRenderedOffset = (scene: RenderedScene, offset: { x: number; y: number }) => {
+ scene.offsetX.set(offset.x)
+ scene.offsetY.set(offset.y)
+}
+
+export const paintRenderedScene = (scene: RenderedScene, geometry: AvatarGeometry) => {
+ scene.headPath.set(geometry.headPath)
+ scene.backNodeIds.current = geometry.backNodeIds
+ scene.frontNodeIds.current = geometry.frontNodeIds
+ scene.backPaths.forEach((path, index) => path.set(geometry.backPaths[index] ?? ''))
+ scene.frontPaths.forEach((path, index) => path.set(geometry.frontPaths[index] ?? ''))
+ scene.leftPath.set(geometry.leftPath)
+ scene.rightPath.set(geometry.rightPath)
+ scene.leftOpacity.set(geometry.leftVisible ? 1 : 0)
+ scene.rightOpacity.set(geometry.rightVisible ? 1 : 0)
+ scene.wirePaths.forEach((path, index) => path.set(geometry.wirePaths[index] ?? ''))
+}
+
+export const findBodyNodePath = (scene: RenderedScene, selectedBodyNodeId: 'primary' | string) => {
+ if (selectedBodyNodeId === 'primary') return scene.headPath
+ const backIndex = scene.backNodeIds.current.indexOf(selectedBodyNodeId)
+ if (backIndex >= 0) return scene.backPaths[backIndex]
+ const frontIndex = scene.frontNodeIds.current.indexOf(selectedBodyNodeId)
+ return frontIndex >= 0 ? scene.frontPaths[frontIndex] : null
+}
diff --git a/src/sequences.ts b/src/sequences.ts
new file mode 100644
index 0000000..d02b75a
--- /dev/null
+++ b/src/sequences.ts
@@ -0,0 +1,288 @@
+import {
+ getStatePlaybackConfig,
+ initialExpressions,
+ stateGroups,
+ stateNotes,
+ statePools,
+} from './presets'
+import type { Expression } from './geometry'
+
+export type SequencePlaybackMode = 'loop' | 'once' | 'pingPong'
+export type SequenceTransition = 'spring' | 'smooth' | 'snappy'
+
+export type SequenceStep = {
+ id: string
+ expressionId: string
+ holdMs: number
+ transitionMs: number
+ transition: SequenceTransition
+}
+
+export type BlinkSettings = {
+ enabled: boolean
+ initialDelayMs: number
+ minIntervalMs: number
+ maxIntervalMs: number
+ durationMs: number
+}
+
+export type AvatarSequence = {
+ id: string
+ name: string
+ group: string
+ description: string
+ builtIn: boolean
+ playbackMode: SequencePlaybackMode
+ steps: SequenceStep[]
+ blink: BlinkSettings
+}
+
+export type SequenceCursor = {
+ index: number
+ direction: 1 | -1
+ complete: boolean
+}
+
+const playbackModes: SequencePlaybackMode[] = ['loop', 'once', 'pingPong']
+const transitions: SequenceTransition[] = ['spring', 'smooth', 'snappy']
+const builtInSequenceIds = new Set(Object.values(stateGroups).flat())
+
+const finite = (value: unknown, fallback: number, min: number, max: number) =>
+ typeof value === 'number' && Number.isFinite(value)
+ ? Math.min(Math.max(value, min), max)
+ : fallback
+
+const createId = (prefix: string) =>
+ `${prefix}-${typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`}`
+
+const cloneSequence = (sequence: AvatarSequence): AvatarSequence => ({
+ ...sequence,
+ steps: sequence.steps.map(step => ({ ...step })),
+ blink: { ...sequence.blink },
+})
+
+export const createInitialSequences = (): AvatarSequence[] =>
+ Object.entries(stateGroups).flatMap(([group, stateIds]) =>
+ stateIds.map(id => {
+ const playback = getStatePlaybackConfig(id)
+ return {
+ id,
+ name: id,
+ group,
+ description:
+ stateNotes[id] ?? 'Cette animation enchaîne un pool de presets et des clignements.',
+ builtIn: true,
+ playbackMode: 'loop',
+ steps: (statePools[id] ?? [0]).map((expressionIndex, index) => ({
+ id: `${id}-step-${index}`,
+ expressionId: initialExpressions[expressionIndex]?.id ?? initialExpressions[0].id,
+ holdMs: playback.expressionIntervalMs,
+ transitionMs: 500,
+ transition: 'smooth',
+ })),
+ blink: { enabled: true, ...playback.blink },
+ }
+ })
+ )
+
+const parseStep = (value: unknown, fallback: SequenceStep): SequenceStep => {
+ const candidate = value as Partial | null
+ return {
+ id: typeof candidate?.id === 'string' ? candidate.id : createId('step'),
+ expressionId:
+ typeof candidate?.expressionId === 'string' && candidate.expressionId
+ ? candidate.expressionId
+ : fallback.expressionId,
+ holdMs: finite(candidate?.holdMs, fallback.holdMs, 100, 60000),
+ transitionMs: finite(candidate?.transitionMs, fallback.transitionMs, 0, 5000),
+ transition: transitions.includes(candidate?.transition as SequenceTransition)
+ ? (candidate?.transition as SequenceTransition)
+ : fallback.transition,
+ }
+}
+
+const parseSequence = (value: unknown, fallback: AvatarSequence): AvatarSequence => {
+ const candidate = value as Partial | null
+ const fallbackStep = fallback.steps[0] ?? {
+ id: 'fallback-step',
+ expressionId: initialExpressions[0].id,
+ holdMs: 3000,
+ transitionMs: 500,
+ transition: 'smooth' as const,
+ }
+ const steps = Array.isArray(candidate?.steps)
+ ? candidate.steps.map(step => parseStep(step, fallbackStep))
+ : fallback.steps.map(step => ({ ...step }))
+ const storedBlink = candidate?.blink as Partial | undefined
+ const minIntervalMs = finite(storedBlink?.minIntervalMs, fallback.blink.minIntervalMs, 100, 60000)
+ return {
+ id: typeof candidate?.id === 'string' ? candidate.id : fallback.id,
+ name:
+ typeof candidate?.name === 'string' && candidate.name.trim() ? candidate.name : fallback.name,
+ group:
+ typeof candidate?.group === 'string' && candidate.group.trim()
+ ? candidate.group
+ : fallback.group,
+ description:
+ typeof candidate?.description === 'string' ? candidate.description : fallback.description,
+ builtIn:
+ typeof candidate?.builtIn === 'boolean'
+ ? candidate.builtIn
+ : typeof candidate?.id === 'string' && builtInSequenceIds.has(candidate.id),
+ playbackMode: playbackModes.includes(candidate?.playbackMode as SequencePlaybackMode)
+ ? (candidate?.playbackMode as SequencePlaybackMode)
+ : fallback.playbackMode,
+ steps,
+ blink: {
+ enabled:
+ typeof storedBlink?.enabled === 'boolean' ? storedBlink.enabled : fallback.blink.enabled,
+ initialDelayMs: finite(storedBlink?.initialDelayMs, fallback.blink.initialDelayMs, 0, 60000),
+ minIntervalMs,
+ maxIntervalMs: Math.max(
+ minIntervalMs,
+ finite(storedBlink?.maxIntervalMs, fallback.blink.maxIntervalMs, 100, 60000)
+ ),
+ durationMs: finite(storedBlink?.durationMs, fallback.blink.durationMs, 40, 3000),
+ },
+ }
+}
+
+export const parseSequences = (value: unknown): AvatarSequence[] => {
+ const defaults = createInitialSequences()
+ if (!Array.isArray(value)) return defaults
+ if (!value.length) return []
+ const fallback = defaults.find(sequence => sequence.id === 'idle') ?? defaults[0]
+ const seen = new Set()
+ return value
+ .map(sequence => parseSequence(sequence, fallback))
+ .filter(sequence => {
+ if (seen.has(sequence.id)) return false
+ seen.add(sequence.id)
+ return true
+ })
+}
+
+export const normalizeSequencesForExpressions = (
+ sequences: AvatarSequence[],
+ expressions: Expression[]
+) => {
+ const availableIds = new Set(expressions.map(expression => expression.id))
+ const fallbackId = expressions[0]?.id ?? initialExpressions[0].id
+ return sequences.map(sequence => ({
+ ...sequence,
+ steps: (sequence.steps.length ? sequence.steps : [createSequenceStep(fallbackId)]).map(
+ step => ({
+ ...step,
+ expressionId: availableIds.has(step.expressionId) ? step.expressionId : fallbackId,
+ })
+ ),
+ }))
+}
+
+export const findExpressionIndex = (expressions: Expression[], expressionId: string) =>
+ expressions.findIndex(expression => expression.id === expressionId)
+
+export const readSequenceClock = () => performance.now()
+
+export const groupSequences = (sequences: AvatarSequence[]) => {
+ const groups: { name: string; sequences: AvatarSequence[] }[] = []
+ const indexes = new Map()
+ sequences.forEach(sequence => {
+ const existingIndex = indexes.get(sequence.group)
+ if (existingIndex !== undefined) {
+ groups[existingIndex].sequences.push(sequence)
+ return
+ }
+ indexes.set(sequence.group, groups.length)
+ groups.push({ name: sequence.group, sequences: [sequence] })
+ })
+ return groups
+}
+
+export const createSequence = (expressionId = initialExpressions[0].id): AvatarSequence => ({
+ id: createId('sequence'),
+ name: 'Untitled animation',
+ group: 'Custom',
+ description: '',
+ builtIn: false,
+ playbackMode: 'loop',
+ steps: [
+ {
+ id: createId('step'),
+ expressionId,
+ holdMs: 3000,
+ transitionMs: 500,
+ transition: 'smooth',
+ },
+ ],
+ blink: {
+ enabled: true,
+ initialDelayMs: 2600,
+ minIntervalMs: 3400,
+ maxIntervalMs: 6200,
+ durationMs: 280,
+ },
+})
+
+export const duplicateSequence = (source: AvatarSequence): AvatarSequence => ({
+ ...cloneSequence(source),
+ id: createId('sequence'),
+ name: `${source.name} copy`,
+ builtIn: false,
+ steps: source.steps.map(step => ({ ...step, id: createId('step') })),
+})
+
+export const createSequenceStep = (expressionId: string): SequenceStep => ({
+ id: createId('step'),
+ expressionId,
+ holdMs: 3000,
+ transitionMs: 500,
+ transition: 'smooth',
+})
+
+export const advanceSequenceCursor = (
+ sequence: AvatarSequence,
+ index: number,
+ direction: 1 | -1
+): SequenceCursor => {
+ const lastIndex = sequence.steps.length - 1
+ if (lastIndex < 0) return { index: 0, direction: 1, complete: true }
+ if (sequence.playbackMode === 'once') {
+ if (index >= lastIndex) return { index: lastIndex, direction: 1, complete: true }
+ return { index: index + 1, direction: 1, complete: false }
+ }
+ if (sequence.playbackMode === 'pingPong' && lastIndex > 0) {
+ if (direction === 1 && index >= lastIndex)
+ return { index: lastIndex - 1, direction: -1, complete: false }
+ if (direction === -1 && index <= 0) return { index: 1, direction: 1, complete: false }
+ return { index: index + direction, direction, complete: false }
+ }
+ return { index: index >= lastIndex ? 0 : index + 1, direction: 1, complete: false }
+}
+
+export const remapSequencesAfterExpressionDelete = (
+ sequences: AvatarSequence[],
+ deletedExpressionId: string,
+ fallbackExpressionId: string
+) =>
+ sequences.map(sequence => {
+ const steps = sequence.steps.filter(step => step.expressionId !== deletedExpressionId)
+ return {
+ ...sequence,
+ steps: steps.length ? steps : [createSequenceStep(fallbackExpressionId)],
+ }
+ })
+
+export const getSequenceSpring = (
+ transition: SequenceTransition,
+ durationMs: number,
+ baseSpeed: number
+) => {
+ const durationFactor = Math.min(Math.max(500 / Math.max(durationMs, 100), 0.35), 3)
+ const styleFactor = transition === 'smooth' ? 0.72 : transition === 'snappy' ? 1.45 : 1
+ const speed = Math.max(baseSpeed * durationFactor * styleFactor, 0.5)
+ return {
+ stiffness: 70 + speed * 24,
+ damping: (17 + speed * 1.7) * (transition === 'smooth' ? 1.18 : 1),
+ }
+}
diff --git a/src/snapshotExporter.ts b/src/snapshotExporter.ts
new file mode 100644
index 0000000..7a373d7
--- /dev/null
+++ b/src/snapshotExporter.ts
@@ -0,0 +1,84 @@
+import type { AvatarColors } from './avatars'
+import type { RenderedScene } from './renderedScene'
+
+export type SnapshotBackground = 'transparent' | 'solid' | 'linear' | 'radial'
+
+export type SnapshotOptions = {
+ background: SnapshotBackground
+ colorFrom: string
+ colorTo: string
+ size: number
+}
+
+const escapeXml = (value: string) =>
+ value.replace(/[&<>"]/g, character => {
+ const entities: Record = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ }
+ return entities[character]
+ })
+
+const path = (value: string, fill: string, opacity = 1) =>
+ value ? ` ` : ''
+
+const backgroundMarkup = (options: SnapshotOptions) => {
+ if (options.background === 'transparent') return ''
+ const fill =
+ options.background === 'solid'
+ ? options.colorFrom
+ : options.background === 'linear'
+ ? 'url(#snapshot-linear)'
+ : 'url(#snapshot-radial)'
+ return ` `
+}
+
+const gradientMarkup = (options: SnapshotOptions) => {
+ if (options.background === 'linear') {
+ return ` `
+ }
+ if (options.background === 'radial') {
+ return ` `
+ }
+ return ''
+}
+
+export const serializeAvatarSnapshot = (
+ name: string,
+ scene: RenderedScene,
+ colors: AvatarColors,
+ options: SnapshotOptions
+) => {
+ const headPath = scene.headPath.get()
+ const backPaths = scene.backPaths.map(item => item.get()).filter(Boolean)
+ const frontPaths = scene.frontPaths.map(item => item.get()).filter(Boolean)
+ const offsetX = scene.offsetX.get()
+ const offsetY = scene.offsetY.get()
+ const body = [
+ ...backPaths.map(value => path(value, colors.body)),
+ path(headPath, colors.body),
+ `${path(scene.leftPath.get(), colors.eyes, scene.leftOpacity.get())}${path(scene.rightPath.get(), colors.eyes, scene.rightOpacity.get())} `,
+ ...frontPaths.map(value => path(value, colors.body)),
+ ].join('')
+
+ return `
+
+ ${gradientMarkup(options)}
+ ${backgroundMarkup(options)}
+ ${body}
+ `
+}
+
+export const snapshotFileName = (name: string, extension: 'svg' | 'png' = 'svg') => {
+ const slug =
+ name
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '') || 'avatar'
+ return `${slug}-snapshot.${extension}`
+}
diff --git a/src/standaloneEngine.entry.ts b/src/standaloneEngine.entry.ts
new file mode 100644
index 0000000..3c09894
--- /dev/null
+++ b/src/standaloneEngine.entry.ts
@@ -0,0 +1,2 @@
+export { expressionFields, poseFromExpression, renderAvatar } from './geometry'
+export { ambientBodyOffset, applyAmbientMotion, hasAmbientMotion } from './ambientMotion'
diff --git a/src/standaloneEngine.generated.ts b/src/standaloneEngine.generated.ts
new file mode 100644
index 0000000..e7f91ec
--- /dev/null
+++ b/src/standaloneEngine.generated.ts
@@ -0,0 +1,2 @@
+// Generated by scripts/generate-standalone-engine.mjs. Do not edit.
+export const standaloneEngineSource = "var AvatarProceduralEngine=(function(e){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var t=(e,t)=>Math.sign(e)*Math.abs(e)**t,n=(e,n,r,i,a,o,s)=>{let c=t(Math.cos(n),o);return[r/2*c*t(Math.sin(e),s),i/2*t(Math.sin(n),o),a/2*c*t(Math.cos(e),s)]},r=(e,t,n)=>{let r=e.width/2,i=e.depth/2,a=Math.min(r,e.height/2),o=Math.max(0,(e.height-a*2)/2),s=o*2+Math.PI*a,c=(n+Math.PI/2)/Math.PI*s,l=r,u=0;if(cMath.max(0,Math.min(2,e??0)),a=e=>1+i(e.roundness)/2,o=.04,s=e=>e.roundness<=0?1/0:2/(o+i(e.roundness)/2*.96),c=(e,t,n,r)=>{let i=Math.cos(n)*Math.sin(t),a=Math.sin(n),o=Math.cos(n)*Math.cos(t),s=Number.isFinite(r)?(Math.abs(i)**r+Math.abs(a)**r+Math.abs(o)**r)**(1/r)||1:Math.max(Math.abs(i),Math.abs(a),Math.abs(o))||1;return[e.width/2*(i/s),e.height/2*(a/s),e.depth/2*(o/s)]},l=(e,t,n)=>c(e,t,n,a(e)),u=(e,t,n)=>c(e,t,n,s(e)),d=.24,f=.2,p=.22,m=e=>i(e.morphRoundness)/2,h=(e,t,n)=>{let r=m(e),i=Math.max(0,Math.min(1,t)),a=Math.sin(i*Math.PI),o=(1-Math.cos(i*Math.PI))/2;return{radiusScale:n.radiusScale+(a-n.radiusScale)*r,verticalProgress:n.verticalProgress+(o-n.verticalProgress)*r}},g=(e,t,n,r,i)=>{let a=1-i;return a**3*e+3*a*a*i*t+3*a*i*i*n+i**3*r},_=e=>({tipFraction:(e.tipRoundness??0)*d,baseFraction:(e.baseRoundness??0)*f}),v=(e,t)=>{let n=Math.max(0,Math.min(1,t)),r=e.roundness*p;if(r<=0)return{radiusScale:1,verticalProgress:(Math.sin((n-.5)*Math.PI)+1)/2};if(n1-r){let e=(n-(1-r))/r*(Math.PI/2);return{radiusScale:1-r+r*Math.cos(e),verticalProgress:1-r/2+r*Math.sin(e)/2}}let i=(n-r)/(1-r*2);return{radiusScale:1,verticalProgress:r/2+i*(1-r)}},y=(e,t)=>h(e,t,v(e,t)),b=(e,t,n)=>{let r=Math.max(0,Math.min(1,t)),i=0,a=1;for(let t=0;t<14;t+=1){let t=(i+a)/2;n(e,t).verticalProgress{let n=Math.max(0,Math.min(1,t)),{tipFraction:r,baseFraction:i}=_(e);if(i>0&&n0&&n>1-r){let e=(n-(1-r))/r;return{radiusScale:g(r,r/2,r/4,0,e),verticalProgress:g(1-r,1-r/2,1,1,e)}}return{radiusScale:1-n,verticalProgress:n}},S=(e,t)=>h(e,t,x(e,t)),C=e=>{let t=e.height*.36,n=e.height-t;return{coneApexY:-e.height/2,coneBaseY:-e.height/2+t,bodyHeight:n,bodyCenterY:e.height/2-n/2,bodyWidth:e.width*.54,bodyDepth:e.depth*.62}},w=(e,t,i)=>{let{width:a,height:o,depth:s}=e;switch(e.type){case`sphere`:case`mickey`:return n(t,i,a,o,s,1,1);case`cube`:return u(e,t,i);case`cylinder`:{let n=y(e,(i+Math.PI/2)/Math.PI);return[a/2*n.radiusScale*Math.sin(t),-o/2+o*n.verticalProgress,s/2*n.radiusScale*Math.cos(t)]}case`cursor`:{let n=C(e),r=(i+Math.PI/2)/Math.PI,a=v({...e,width:n.bodyWidth,height:n.bodyHeight,depth:n.bodyDepth},r);return[n.bodyWidth/2*a.radiusScale*Math.sin(t),n.bodyCenterY-n.bodyHeight/2+n.bodyHeight*a.verticalProgress,n.bodyDepth/2*a.radiusScale*Math.cos(t)]}case`diamond`:return l(e,t,i);case`capsule`:return r(e,t,i);case`cone`:{let n=S(e,(i+Math.PI/2)/Math.PI);return[a/2*n.radiusScale*Math.sin(t),o/2-o*n.verticalProgress,s/2*n.radiusScale*Math.cos(t)]}}},ee=(e,t)=>[e[0]-t[0],e[1]-t[1],e[2]-t[2]],T=([e,t,n])=>{let r=Math.hypot(e,t,n)||1;return[e/r,t/r,n/r]},te=(e,t,n)=>{let r=e.type===`cone`?-1:1;return T([r*(t[1]*n[2]-t[2]*n[1]),r*(t[2]*n[0]-t[0]*n[2]),r*(t[0]*n[1]-t[1]*n[0])])},ne=(e,t,n)=>{let r=5e-4;if(e.type===`cone`&&n>=Math.PI/2-r)return[0,-1,0];let i=w(e,t-r,n),a=w(e,t+r,n),o=w(e,t,Math.max(-Math.PI/2,n-r)),s=w(e,t,Math.min(Math.PI/2,n+r));return te(e,ee(a,i),ee(s,o))},E=(e,t)=>Math.sign(e)*Math.abs(e)**t,re=(e,t,n)=>{let r=e.width/2||1,i=e.height/2||1,a=e.depth/2||1;return T([E(t[0]/r,n-1)/r,E(t[1]/i,n-1)/i,E(t[2]/a,n-1)/a])},ie=(e,t)=>re(e,t,a(e)),ae=(e,t)=>{let n=s(e);if(Number.isFinite(n))return re(e,t,n);let r=[t[0]/(e.width/2||1),t[1]/(e.height/2||1),t[2]/(e.depth/2||1)],i=r.reduce((e,t,n)=>Math.abs(t)>Math.abs(r[e])?n:e,0);return[i===0?Math.sign(r[0]):0,i===1?Math.sign(r[1]):0,i===2?Math.sign(r[2]):0]},oe=(e,t,n,r,i)=>{let a=e.width/2||1,o=e.height/2||1,s=e.depth/2||1;if(!Number.isFinite(r)){let r=[Math.max(-a,Math.min(a,t)),Math.max(-o,Math.min(o,n)),s];return{point:r,normal:i(e,r)}}let c=Math.max(-1,Math.min(1,n/o)),l=Math.max(0,1-Math.abs(c)**r)**(1/r),u=Math.max(-a*l,Math.min(a*l,t)),d=u/a,f=Math.max(0,1-Math.abs(d)**r-Math.abs(c)**r)**(1/r),p=[u,c*o,s*f];return{point:p,normal:i(e,p)}},se=(e,t,n,r,i,a=0)=>{let o=t-a,s=Math.max(0,1-(e/(n||1))**2-(o/(r||1))**2),c=i*Math.sqrt(s);return{point:[e,t,c],normal:T([e/(n*n||1),o/(r*r||1),c/(i*i||1)])}},D=(e,t,n,r,i)=>{let a=e.width/2||1,o=e.depth/2||1,s=Math.max(0,Math.min(1,.5+i*(n/e.height))),c=b(e,s,r),l=a*c,u=o*c,d=Math.max(-l,Math.min(l,t)),f=l>0?Math.max(0,1-(d/l)**2):0,p=u*Math.sqrt(f),m=1e-4,h=Math.max(0,s-m),g=Math.min(1,s+m),_=b(e,h,r),v=(b(e,g,r)-_)/(g-h||1),y=Math.max(Math.sqrt(f),1e-4),x=-(o/a)*d/(l*y||1),S=i*o*v/(e.height*y||1);return{point:[d,n,p],normal:T([-x,-S,1])}},ce=(e,t,n)=>{let r=e.width/2||1,i=e.height/2||1,o=e.depth/2||1;switch(e.type){case`sphere`:case`mickey`:return se(t,n,r,i,o);case`cube`:return oe(e,t,n,s(e),ae);case`capsule`:{let e=Math.min(r,i),a=Math.max(0,i-e);return se(t,n,r,e,o,n<-a?-a:n>a?a:n)}case`cylinder`:return D(e,t,n,y,1);case`cursor`:{let r=C(e),i=D({...e,width:r.bodyWidth,height:r.bodyHeight,depth:r.bodyDepth},t,n-r.bodyCenterY,v,1);return{point:[i.point[0],i.point[1]+r.bodyCenterY,i.point[2]],normal:i.normal}}case`cone`:return D(e,t,n,S,-1);case`diamond`:return oe(e,t,n,a(e),ie)}},le=(e,t,n)=>{let r=w(e,t,n);if(e.type===`sphere`||e.type===`mickey`){let t=e.width/2||1,n=e.height/2||1,i=e.depth/2||1;return{point:r,normal:T([r[0]/(t*t),r[1]/(n*n),r[2]/(i*i)])}}return e.type===`cylinder`&&e.roundness<=0&&(e.morphRoundness??0)<=0?{point:r,normal:T([Math.sin(t)/(e.width/2||1),0,Math.cos(t)/(e.depth/2||1)])}:e.type===`diamond`?{point:r,normal:ie(e,r)}:e.type===`cube`?{point:r,normal:ae(e,r)}:{point:r,normal:ne(e,t,n)}},O=620,ue=14,de=[`headX`,`headY`,`headZ`,`widthLeft`,`widthRight`,`heightLeft`,`heightRight`,`spacing`,`positionXLeft`,`positionXRight`,`positionYLeft`,`positionYRight`,`leftAngle`,`rightAngle`,`perspective`],k=e=>e*Math.PI/180,fe=([e,t,n,r])=>{let i=Math.hypot(e,t,n,r)||1;return[e/i,t/i,n/i,r/i]},pe=([e,t,n,r],[i,a,o,s])=>fe([e*i-t*a-n*o-r*s,e*a+t*i+n*s-r*o,e*o-t*s+n*i+r*a,e*s+t*o-n*a+r*i]),A=([e,t,n],r)=>{let i=r/2,a=Math.sin(i);return fe([Math.cos(i),e*a,t*a,n*a])},j=(e,t,n)=>{let r=A([1,0,0],e),i=A([0,1,0],t);return pe(pe(A([0,0,1],n),r),i)},M=([e,t,n,r],[i,a,o])=>{let s=2*(n*o-r*a),c=2*(r*i-t*o),l=2*(t*a-n*i);return[i+e*s+(n*l-r*c),a+e*c+(r*s-t*l),o+e*l+(t*c-n*s)]},me=(e,t)=>{let n=e/2,r=t/2,i=Math.min(r,n),a=[],o=(e,t)=>{let n=Math.max(2,Math.ceil(Math.hypot(t[0]-e[0],t[1]-e[1])/1.5));for(let r=0;r{for(let r=0;r{let n=O-e[2]*t,r=Math.abs(n)<1e-4?O/1e-4:O/n;return[e[0]*r,e[1]*r,e[2]]},P=(e,t=!0)=>e.length?`M${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}${e.slice(1).map(e=>`L${e[0].toFixed(2)} ${e[1].toFixed(2)}`).join(``)}${t?`Z`:``}`:``,he=e=>({expression:e,orientation:j(k(e.headX),k(e.headY),k(e.headZ))}),ge=24,_e=25,ve=73,F=144,ye=33,be=73,I=new Map,L=new Map,R=new Map,z=e=>[e.type,e.width,e.height,e.depth,e.roundness,e.morphRoundness,e.tipRoundness,e.baseRoundness].map(e=>typeof e==`number`?e.toFixed(4):e).join(`:`),B=(e,t,n)=>(e.size>=ge&&e.delete(e.keys().next().value),e.set(t,n),n),V=(e,t,n)=>le(e,t,n),xe=(e,t)=>({point:N(M(e.orientation,t.point),e.expression.perspective),normal:M(e.orientation,t.normal)}),Se=(e,t)=>{let n=e/120,r=t/120;return[120*Math.cos(r)*Math.sin(n),120*Math.sin(r)]},Ce=(e,t,n,r)=>{let[i,a]=Se(n,r);return xe(e,ce(t,i,a))},we=(e,t,n,r)=>{let i=e.expression,a=n<0?`Left`:`Right`,o=i[`width${a}`],s=5+(i[`height${a}`]-5)*r,c=n*i.spacing/2+i[`positionX${a}`],l=i[`positionY${a}`],u=k(n<0?i.leftAngle:i.rightAngle);return me(o,s).map(([n,r])=>{let i=n*Math.cos(u)-r*Math.sin(u),a=n*Math.sin(u)+r*Math.cos(u);return Ce(e,t,c+i,l+a)})},Te=e=>{let t=[],n=[];return e.forEach(({point:e,normal:r})=>{r[2]>0?n.push(e):n.length&&(t.push(n),n=[])}),n.length&&t.push(n),t.filter(e=>e.length>1).map(e=>P(e,!1)).join(``)},Ee=(e,t)=>{let n=z(t),r=R.get(n);if(!r){let e=[-60,-30,0,30,60].map(e=>Array.from({length:73},(n,r)=>V(t,k(-180+r*5),k(e)))),i=Array.from({length:12},(e,t)=>-150+t*30).map(e=>Array.from({length:37},(n,r)=>V(t,k(e),k(-90+r*5))));r=B(R,n,[...e,...i])}return r.map(t=>Te(t.map(t=>xe(e,t))))},H=e=>{let t=[...e].sort((e,t)=>e[0]-t[0]||e[1]-t[1]),n=(e,t,n)=>(t[0]-e[0])*(n[1]-e[1])-(t[1]-e[1])*(n[0]-e[0]),r=e=>{let t=[];return e.forEach(e=>{for(;t.length>=2&&n(t.at(-2),t.at(-1),e)<=0;)t.pop();t.push(e)}),t};return[...r(t).slice(0,-1),...r(t.reverse()).slice(0,-1)]},U=e=>{if(e.length<3)return P(e);let t=t=>e[(t+e.length)%e.length];return`M${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}${e.map((e,n)=>{let r=t(n-1),i=t(n+1),a=t(n+2),o=[e[0]+(i[0]-r[0])/6,e[1]+(i[1]-r[1])/6,e[2]],s=[i[0]-(a[0]-e[0])/6,i[1]-(a[1]-e[1])/6,i[2]];return`C${o[0].toFixed(2)} ${o[1].toFixed(2)} ${s[0].toFixed(2)} ${s[1].toFixed(2)} ${i[0].toFixed(2)} ${i[1].toFixed(2)}`}).join(``)}Z`},W=(e,t=7)=>e.flatMap((n,r)=>{let i=e[(r+1)%e.length],a=Math.max(1,Math.ceil(Math.hypot(i[0]-n[0],i[1]-n[1])/t));return Array.from({length:a},(e,t)=>{let r=t/a;return[n[0]+(i[0]-n[0])*r,n[1]+(i[1]-n[1])*r,n[2]+(i[2]-n[2])*r]})}),De=e=>e.length?e.length===1?`${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}`:e.slice(0,-1).map((t,n)=>{let r=e[Math.max(0,n-1)],i=e[n+1],a=e[Math.min(e.length-1,n+2)],o=t[0]+(i[0]-r[0])/6,s=t[1]+(i[1]-r[1])/6,c=i[0]-(a[0]-t[0])/6,l=i[1]-(a[1]-t[1])/6;return`C${o.toFixed(2)} ${s.toFixed(2)} ${c.toFixed(2)} ${l.toFixed(2)} ${i[0].toFixed(2)} ${i[1].toFixed(2)}`}).join(``):``,G=(e,t)=>N(M(e.orientation,t),e.expression.perspective),K=(e,t,n)=>Array.from({length:145},(r,i)=>{let a=i/F*Math.PI*2;return[e/2*Math.sin(a),n,t/2*Math.cos(a)]}),q=(e,t)=>{let n=z(t),r=I.get(n);return r||(r=Array.from({length:ye},(e,n)=>{let r=-Math.PI/2+n/32*Math.PI;return Array.from({length:be},(e,n)=>w(t,-Math.PI+n/72*Math.PI*2,r))}).flat(),B(I,n,r)),U(W(H(r.map(t=>G(e,t)))))},Oe=(e,t)=>{if(t.roundness>0||(t.morphRoundness??0)>0)return q(e,t);let n=t.height/2;return U(W(H([...K(t.width,t.depth,-n),...K(t.width,t.depth,n)].map(t=>G(e,t)))))},ke=(e,t)=>{let n=C(t),r=n.bodyHeight/2;return U(W(H([...K(n.bodyWidth,n.bodyDepth,n.bodyCenterY-r),...K(n.bodyWidth,n.bodyDepth,n.bodyCenterY+r)].map(t=>G(e,t)))))},Ae=(e,t)=>{let n=C(t),r=G(e,[0,n.coneApexY,0]);return U(W(H([...K(t.width,t.depth,n.coneBaseY).map(t=>G(e,t)),r])))},je=(e,t)=>{if((t.morphRoundness??0)>0||(t.tipRoundness??0)>0||(t.baseRoundness??0)>0)return q(e,t);let n=G(e,[0,-t.height/2,0]),r=H([...K(t.width,t.depth,t.height/2).map(t=>G(e,t)),n]),i=r.findIndex(e=>Math.hypot(e[0]-n[0],e[1]-n[1])<.01);if(i<0)return U(r);let a=[...r.slice(i),...r.slice(0,i)].slice(1);return a.length<2?P(r):`M${n[0].toFixed(2)} ${n[1].toFixed(2)}L${a[0][0].toFixed(2)} ${a[0][1].toFixed(2)}${De(a)}L${n[0].toFixed(2)} ${n[1].toFixed(2)}Z`},Me=(e,t)=>{if(t.roundness>0)return q(e,t);let n=t.width/2,r=t.height/2,i=t.depth/2;return P(H([-1,1].flatMap(e=>[-1,1].flatMap(t=>[-1,1].map(a=>[e*n,t*r,a*i]))).map(t=>G(e,t))))},Ne=(e,t)=>{if(t.roundness>0)return q(e,t);let n=t.width/2,r=t.height/2,i=t.depth/2;return P(H([[-n,0,0],[n,0,0],[0,-r,0],[0,r,0],[0,0,-i],[0,0,i]].map(t=>G(e,t))))},Pe=(e,t,n,r,i)=>{let a=n+i,o=Math.hypot(n-i,r*2),s=(a+o)/2,c=(a-o)/2;return s<=0||c<=0?null:{centerX:e,centerY:t,majorRadius:Math.sqrt(s),minorRadius:Math.sqrt(c),rotation:Math.atan2(r*2,n-i)/2}},J=({centerX:e,centerY:t,majorRadius:n,minorRadius:r,rotation:i})=>{let a=i*180/Math.PI,o=Math.cos(i)*n,s=Math.sin(i)*n,c=e+o,l=t+s,u=e-o,d=t-s;return`M${c.toFixed(2)} ${l.toFixed(2)}A${n.toFixed(2)} ${r.toFixed(2)} ${a.toFixed(2)} 0 1 ${u.toFixed(2)} ${d.toFixed(2)}A${n.toFixed(2)} ${r.toFixed(2)} ${a.toFixed(2)} 0 1 ${c.toFixed(2)} ${l.toFixed(2)}Z`},Y=(e,t,n=[0,0,0])=>{let r=[M(e.orientation,[1,0,0]),M(e.orientation,[0,1,0]),M(e.orientation,[0,0,1])],i=M(e.orientation,n);if(Math.abs(e.expression.perspective)<1e-4){let e=r.reduce((e,n,r)=>e+n[0]*n[0]*t[r]*t[r],0),n=r.reduce((e,n,r)=>e+n[0]*n[1]*t[r]*t[r],0),a=r.reduce((e,n,r)=>e+n[1]*n[1]*t[r]*t[r],0);return Pe(i[0],i[1],e,n,a)}let a=t.map(e=>1/(e*e)),o=Array.from({length:3},(e,t)=>Array.from({length:3},(e,n)=>r.reduce((e,r,i)=>e+r[t]*a[i]*r[n],0))),s=O/e.expression.perspective,c=[-i[0],-i[1],s-i[2]],l=[o[0][0]*c[0]+o[0][1]*c[1]+o[0][2]*c[2],o[1][0]*c[0]+o[1][1]*c[1]+o[1][2]*c[2],o[2][0]*c[0]+o[2][1]*c[1]+o[2][2]*c[2]],u=c[0]*l[0]+c[1]*l[1]+c[2]*l[2]-1,d=[l[0],l[1],-s*l[2]],f=[[o[0][0],o[0][1],-s*o[0][2]],[o[1][0],o[1][1],-s*o[1][2]],[-s*o[2][0],-s*o[2][1],s*s*o[2][2]]],p=Array.from({length:3},(e,t)=>Array.from({length:3},(e,n)=>d[t]*d[n]-u*f[t][n])),m=p[0][0]*p[1][1]-p[0][1]*p[0][1];if(Math.abs(m)<1e-12)return null;let h=-(p[1][1]*p[0][2]-p[0][1]*p[1][2])/m,g=(p[0][1]*p[0][2]-p[0][0]*p[1][2])/m,_=-(p[2][2]+p[0][2]*h+p[1][2]*g);if(Math.abs(_)<1e-12)return null;let v=p[0][0]/_,y=p[0][1]/_,b=p[1][1]/_,x=v*b-y*y;return x<=0?null:Pe(h,g,b/x,-y/x,v/x)},Fe=(e,t)=>{let n=Y(e,[t.width/2,t.height/2,t.depth/2]),r=t.width===t.height&&t.height===t.depth;if(n&&r){let e=(n.majorRadius+n.minorRadius)/2;return J({centerX:0,centerY:0,majorRadius:e,minorRadius:e,rotation:0})}return n?J(n):null},Ie=(e,t)=>{if(t.type!==`mickey`)return[];let n=Math.min(t.width,t.height)*.23,r=Math.min(n,t.depth*.29),i=t.width*.37,a=-t.height*.39,o=-t.depth*.12,s=[n,n,r];return[-1,1].map(t=>Y(e,s,[t*i,a,o])).filter(e=>e!==null).map(J)},Le=(e,t)=>t.type===`mickey`?Ie(e,t):t.type===`cursor`?[Ae(e,t)]:[],X=e=>Array.from({length:F},(t,n)=>{let r=n/F*Math.PI*2,i=Math.cos(r)*e.majorRadius,a=Math.sin(r)*e.minorRadius;return[e.centerX+i*Math.cos(e.rotation)-a*Math.sin(e.rotation),e.centerY+i*Math.sin(e.rotation)+a*Math.cos(e.rotation),0]}),Re=e=>{if(e.length<3)return P(e);let t=e.map((t,n)=>{let r=e[(n+1)%e.length];return Math.hypot(r[0]-t[0],r[1]-t[1])}),n=[...t].sort((e,t)=>e-t),r=n[Math.floor(n.length/2)]||1,i=Math.max(8,r*3.5),a=t.map(e=>e>i);return`M${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}${e.map((t,n)=>{let r=(n+1)%e.length,i=e[r];if(a[n])return`L${i[0].toFixed(2)} ${i[1].toFixed(2)}`;let o=a[(n-1+e.length)%e.length]?t:e[(n-1+e.length)%e.length],s=a[r]?i:e[(n+2)%e.length],c=t[0]+(i[0]-o[0])/6,l=t[1]+(i[1]-o[1])/6,u=i[0]-(s[0]-t[0])/6,d=i[1]-(s[1]-t[1])/6;return`C${c.toFixed(2)} ${l.toFixed(2)} ${u.toFixed(2)} ${d.toFixed(2)} ${i[0].toFixed(2)} ${i[1].toFixed(2)}`}).join(``)}Z`},ze=(e,t)=>{let n=t.width/2,r=Math.min(n,t.height/2),i=t.depth/2,a=Math.max(0,(t.height-r*2)/2),o=[n,r,i],s=Y(e,o,[0,a,0]),c=Y(e,o,[0,-a,0]);return!s||!c?null:Re(H([...X(s),...X(c)]))},Be=(e,t)=>{if(t.type===`sphere`||t.type===`mickey`){let n=Fe(e,t);if(n)return n}if(t.type===`capsule`){let n=ze(e,t);if(n)return n}if(t.type===`cylinder`)return Oe(e,t);if(t.type===`cursor`)return ke(e,t);if(t.type===`cone`)return je(e,t);if(t.type===`cube`)return Me(e,t);if(t.type===`diamond`)return Ne(e,t);let n=z(t),r=I.get(n);return r||(r=Array.from({length:_e},(e,n)=>{let r=-Math.PI/2+n/24*Math.PI;return Array.from({length:ve},(e,n)=>w(t,-Math.PI+n/72*Math.PI*2,r))}).flat(),B(I,n,r)),P(H(r.map(t=>N(M(e.orientation,t),e.expression.perspective))))},Ve=(e,t)=>{let n=z(t.surface),r=L.get(n);r||(r=Array.from({length:17},(e,n)=>{let r=-Math.PI/2+n/16*Math.PI;return Array.from({length:49},(e,n)=>{let i=-Math.PI+n/48*Math.PI*2;return w(t.surface,i,r)})}).flat(),B(L,n,r));let i=j(k(t.rotation[0]),k(t.rotation[1]),k(t.rotation[2])),a=H(r.map(n=>{let r=M(i,n),a=[r[0]+t.position[0],r[1]+t.position[1],r[2]+t.position[2]];return N(M(e.orientation,a),e.expression.perspective)}));return(t.surface.type===`cube`||t.surface.type===`diamond`)&&t.surface.roundness<=0?P(a):U(W(a))},He=.1,Ue=(e,t)=>{let n=j(k(t.rotation[0]),k(t.rotation[1]),k(t.rotation[2])),r=[[1,0,0],[0,1,0],[0,0,1]].map(t=>M(e.orientation,M(n,t))[2]);return Math.hypot(r[0]*(t.surface.width/2),r[1]*(t.surface.height/2),r[2]*(t.surface.depth/2))},We=(e,t)=>{let n=t.map(t=>{let n=M(e.orientation,t.position)[2];return{id:t.id,path:Ve(e,t),depth:n,front:n>Ue(e,t)*He}}).sort((e,t)=>e.depth-t.depth);return{backPaths:n.filter(e=>!e.front).map(e=>e.path),frontPaths:n.filter(e=>e.front).map(e=>e.path),backNodeIds:n.filter(e=>!e.front).map(e=>e.id),frontNodeIds:n.filter(e=>e.front).map(e=>e.id)}},Ge=(e,t,n=1,r={})=>{let i=we(e,t,-1,n),a=we(e,t,1,n),o=i.map(e=>e.point),s=a.map(e=>e.point),c=We(e,r.bodyNodes??[]),l=Le(e,t);return{backPaths:[...l,...c.backPaths],frontPaths:c.frontPaths,backNodeIds:[...l.map(()=>null),...c.backNodeIds],frontNodeIds:c.frontNodeIds,headPath:Be(e,t),leftPath:P(o),rightPath:P(s),leftVisible:i.reduce((e,t)=>e+t.normal[2],0)>0,rightVisible:a.reduce((e,t)=>e+t.normal[2],0)>0,wirePaths:r.includeWire===!1?[]:Ee(e,t)}},Ke=e=>e*e*(3-2*e),Z=e=>{let t=Math.sin(e*127.1+311.7)*43758.5453;return(t-Math.floor(t))*2-1},qe=e=>e.headX*.71+e.headY*1.13+e.headZ*1.37,Q=(e,t,n,r)=>{let i=e/r,a=Math.floor(i),o=Ke(i-a),s=Z(a*3+t+n);return s+(Z((a+1)*3+t+n)-s)*o},$=(e,t,n)=>{let r=1100,i=e+Math.sin(e/1700+n)*280,a=Math.floor(i/r),o=(i%r+r)%r/r,s=Ke(Math.min(o/.09,1)),c=Z((a-1)*2+t+n),l=Z(a*2+t+n),u=Math.min(e/240,1);return(c+(l-c)*s)*u};return e.ambientBodyOffset=(e,t)=>{let n=qe(e);if(e.bodyMotion===`slowDrift`)return{x:Q(t,3,n,2900)*1.45,y:Q(t,4,n,3700)*1.1};if(e.bodyMotion===`shake`){let e=t/1e3;return{x:(Math.sin(e*31)+Math.sin(e*53)*.45)*1.35,y:(Math.sin(e*37)+Math.sin(e*61)*.4)*1.1}}return{x:0,y:0}},e.applyAmbientMotion=(e,t)=>{let n={...e},r=qe(e);if(e.eyeMotion===`microSaccades`){let e=$(t,0,r)*1.5,i=$(t,1,r)*.9;n.positionXLeft+=e,n.positionXRight+=e,n.positionYLeft+=i,n.positionYRight+=i}else if(e.eyeMotion===`shake`){let e=t/1e3,r=(Math.sin(e*47)+Math.sin(e*71)*.45)*1.2,i=(Math.sin(e*59)+Math.sin(e*83)*.4)*.8;n.positionXLeft+=r,n.positionXRight+=r,n.positionYLeft+=i,n.positionYRight+=i}if(e.bodyMotion===`slowDrift`)n.headX+=Q(t,0,r,2600)*.8,n.headY+=Q(t,1,r,3300)*1.15,n.headZ+=Q(t,2,r,4100)*.45;else if(e.bodyMotion===`shake`){let e=t/1e3;n.headX+=(Math.sin(e*31)+Math.sin(e*53)*.45)*1.15,n.headY+=(Math.sin(e*37)+Math.sin(e*61)*.4)*1.35,n.headZ+=Math.sin(e*43)*.7}return n},e.expressionFields=de,e.hasAmbientMotion=e=>e.eyeMotion!==`none`||e.bodyMotion!==`none`,e.poseFromExpression=he,e.renderAvatar=Ge,e})({});"
diff --git a/src/storedZip.ts b/src/storedZip.ts
new file mode 100644
index 0000000..fd75d5c
--- /dev/null
+++ b/src/storedZip.ts
@@ -0,0 +1,61 @@
+type StoredZipFile = { name: string; content: string }
+
+const crc32 = (bytes: Uint8Array) => {
+ let crc = 0xffffffff
+ for (const byte of bytes) {
+ crc ^= byte
+ for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1))
+ }
+ return (crc ^ 0xffffffff) >>> 0
+}
+
+export const createStoredZip = (files: StoredZipFile[]) => {
+ const encoder = new TextEncoder()
+ const chunks: Uint8Array[] = []
+ const centralEntries: Uint8Array[] = []
+ let offset = 0
+ const header = (size: number) => {
+ const bytes = new Uint8Array(size)
+ return { bytes, view: new DataView(bytes.buffer) }
+ }
+
+ for (const file of files) {
+ const name = encoder.encode(file.name)
+ const content = encoder.encode(file.content)
+ const checksum = crc32(content)
+ const local = header(30 + name.length)
+ local.view.setUint32(0, 0x04034b50, true)
+ local.view.setUint16(4, 20, true)
+ local.view.setUint32(14, checksum, true)
+ local.view.setUint32(18, content.length, true)
+ local.view.setUint32(22, content.length, true)
+ local.view.setUint16(26, name.length, true)
+ local.bytes.set(name, 30)
+ chunks.push(local.bytes, content)
+
+ const central = header(46 + name.length)
+ central.view.setUint32(0, 0x02014b50, true)
+ central.view.setUint16(4, 20, true)
+ central.view.setUint16(6, 20, true)
+ central.view.setUint32(16, checksum, true)
+ central.view.setUint32(20, content.length, true)
+ central.view.setUint32(24, content.length, true)
+ central.view.setUint16(28, name.length, true)
+ central.view.setUint32(42, offset, true)
+ central.bytes.set(name, 46)
+ centralEntries.push(central.bytes)
+ offset += local.bytes.length + content.length
+ }
+
+ const centralSize = centralEntries.reduce((size, entry) => size + entry.length, 0)
+ const end = header(22)
+ end.view.setUint32(0, 0x06054b50, true)
+ end.view.setUint16(8, files.length, true)
+ end.view.setUint16(10, files.length, true)
+ end.view.setUint32(12, centralSize, true)
+ end.view.setUint32(16, offset, true)
+ const parts = [...chunks, ...centralEntries, end.bytes].map(
+ bytes => Uint8Array.from(bytes).buffer as ArrayBuffer
+ )
+ return new Blob(parts, { type: 'application/zip' })
+}
diff --git a/src/studioDocument.ts b/src/studioDocument.ts
new file mode 100644
index 0000000..4ce9572
--- /dev/null
+++ b/src/studioDocument.ts
@@ -0,0 +1,140 @@
+import {
+ parseAvatarLibrary,
+ parseExpressions,
+ type AvatarBehaviorLibrary,
+ type AvatarLibrary,
+} from './avatars'
+import type { Expression } from './geometry'
+import { normalizeSequencesForExpressions, parseSequences, type AvatarSequence } from './sequences'
+import defaultStudioDocument from './defaultStudioDocument.json'
+
+export type StatePlaybackSelection = { stateId: string | null; playing: boolean }
+
+export type StudioDocument = {
+ version: 2
+ library: AvatarLibrary
+ expressions: Expression[]
+ sequences: AvatarSequence[]
+ playback: StatePlaybackSelection
+}
+
+export type StudioDocumentPatch = Partial>
+
+const DOCUMENT_STORAGE_KEY = 'bible-strong-avatar-studio-v2'
+
+const defaultPlayback: StatePlaybackSelection = { stateId: 'idle', playing: true }
+
+const parsePlayback = (
+ value: unknown,
+ fallback: StatePlaybackSelection = defaultPlayback
+): StatePlaybackSelection => {
+ const candidate = value as Partial | null
+ if (!candidate || (typeof candidate.stateId !== 'string' && candidate.stateId !== null)) {
+ return { ...fallback }
+ }
+ return { stateId: candidate.stateId, playing: candidate.playing === true }
+}
+
+export const parseStudioDocument = (value: unknown, fallback: StudioDocument): StudioDocument => {
+ const candidate = value as Partial | null
+ if (!candidate || candidate.version !== 2) return fallback
+ const expressions =
+ Array.isArray(candidate.expressions) && candidate.expressions.length
+ ? parseExpressions(candidate.expressions)
+ : fallback.expressions
+ const sequences = Array.isArray(candidate.sequences)
+ ? normalizeSequencesForExpressions(parseSequences(candidate.sequences), expressions)
+ : fallback.sequences
+ const baseBehavior: AvatarBehaviorLibrary = { expressions, sequences }
+ const library = parseAvatarLibrary(candidate.library, fallback.library, baseBehavior)
+ return {
+ version: 2,
+ library,
+ expressions,
+ sequences,
+ playback: parsePlayback(candidate.playback, fallback.playback),
+ }
+}
+
+export const serializeStudioDocument = (document: StudioDocument) =>
+ JSON.stringify(document, null, 2)
+
+export const parseImportedStudioDocument = (
+ source: string,
+ fallback: StudioDocument
+): StudioDocument => {
+ let value: unknown
+ try {
+ value = JSON.parse(source)
+ } catch {
+ throw new Error('Invalid Avatar Studio project')
+ }
+ const candidate = value as Partial | null
+ if (!candidate || candidate.version !== 2) {
+ throw new Error('Unsupported Avatar Studio project')
+ }
+ if (
+ !candidate.library ||
+ !Array.isArray(candidate.library.avatars) ||
+ !candidate.library.avatars.length ||
+ !Array.isArray(candidate.expressions) ||
+ !candidate.expressions.length ||
+ !Array.isArray(candidate.sequences)
+ ) {
+ throw new Error('Invalid Avatar Studio project')
+ }
+ return parseStudioDocument(candidate, fallback)
+}
+
+const createBundledStudioDocument = () => {
+ const snapshot = JSON.parse(JSON.stringify(defaultStudioDocument)) as StudioDocument
+ return parseStudioDocument(snapshot, snapshot)
+}
+
+export const loadStudioDocument = (
+ storage: Pick = window.localStorage
+): StudioDocument => {
+ const fallback = createBundledStudioDocument()
+ try {
+ return parseStudioDocument(
+ JSON.parse(storage.getItem(DOCUMENT_STORAGE_KEY) ?? 'null'),
+ fallback
+ )
+ } catch {
+ return fallback
+ }
+}
+
+export const persistStudioDocument = (document: StudioDocument) => {
+ try {
+ window.localStorage.setItem(DOCUMENT_STORAGE_KEY, JSON.stringify(document))
+ return true
+ } catch {
+ // The in-memory document remains authoritative when storage is unavailable.
+ return false
+ }
+}
+
+export const createStudioDocumentStore = (
+ initial: StudioDocument,
+ persist: (document: StudioDocument) => void = persistStudioDocument
+) => {
+ let current = initial
+ return {
+ update: (patch: StudioDocumentPatch) => {
+ const expressions = patch.expressions ?? current.expressions
+ current = {
+ ...current,
+ ...patch,
+ version: 2,
+ expressions,
+ sequences: normalizeSequencesForExpressions(
+ patch.sequences ?? current.sequences,
+ expressions
+ ),
+ }
+ persist(current)
+ return current
+ },
+ }
+}
diff --git a/src/styles.css b/src/styles.css
new file mode 100644
index 0000000..06cc349
--- /dev/null
+++ b/src/styles.css
@@ -0,0 +1,2177 @@
+@import 'tailwindcss';
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+}
+
+:root {
+ --background: #ffffff;
+ --foreground: #17191d;
+ --card: #ffffff;
+ --card-foreground: #17191d;
+ --popover: #ffffff;
+ --popover-foreground: #17191d;
+ --primary: #17191d;
+ --primary-foreground: #ffffff;
+ --secondary: #edf1ff;
+ --secondary-foreground: #4168d5;
+ --muted: #f0f1f4;
+ --muted-foreground: #767d88;
+ --destructive: #c83737;
+ --border: #dcdfe6;
+ --input: #d9dce3;
+ --ring: #5b7fe5;
+ color: #17191d;
+ background: #eef0f4;
+ font-family:
+ Inter,
+ ui-sans-serif,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ sans-serif;
+ font-synthesis: none;
+}
+
+* {
+ box-sizing: border-box;
+}
+body {
+ margin: 0;
+ min-width: 320px;
+ min-height: 100vh;
+}
+button,
+input {
+ font: inherit;
+}
+.studio {
+ display: grid;
+ min-height: 100vh;
+ grid-template-columns: minmax(460px, 1fr) minmax(440px, 520px);
+}
+
+.stage-column {
+ position: sticky;
+ top: 0;
+ display: flex;
+ height: 100vh;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ padding: 28px;
+ background: #101316;
+ color: white;
+}
+
+.stage-column::before {
+ position: absolute;
+ width: 70vw;
+ height: 70vw;
+ border-radius: 50%;
+ background: radial-gradient(circle, rgb(91 127 229 / 18%), transparent 68%);
+ content: '';
+ pointer-events: none;
+}
+.photo-flash {
+ position: absolute;
+ z-index: 20;
+ inset: 0;
+ background: white;
+ pointer-events: none;
+}
+.photo-capture-bar {
+ position: absolute;
+ z-index: 8;
+ bottom: 58px;
+ left: 50%;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ transform: translateX(-50%);
+}
+.photo-capture-button {
+ min-width: 190px;
+ height: 44px;
+ padding-inline: 15px 10px;
+ border: 1px solid rgb(255 255 255 / 14%);
+ border-radius: 13px;
+ background: rgb(246 248 252 / 96%);
+ box-shadow:
+ 0 12px 32px rgb(0 0 0 / 28%),
+ inset 0 1px 0 rgb(255 255 255 / 78%);
+ color: #17191d;
+ transition-property: transform, background-color, box-shadow;
+ transition-duration: 160ms;
+ transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
+}
+.photo-capture-button:hover {
+ background: white;
+ box-shadow:
+ 0 15px 36px rgb(0 0 0 / 34%),
+ inset 0 1px 0 white;
+}
+.photo-capture-button:active {
+ transform: scale(0.96);
+}
+.photo-capture-button > svg {
+ width: 17px;
+ height: 17px;
+}
+.photo-format-badge {
+ margin-left: auto;
+ padding: 3px 6px;
+ border-radius: 6px;
+ background: #e9edfa;
+ color: #4168d5;
+ font-size: 9px;
+ font-weight: 850;
+ letter-spacing: 0.08em;
+}
+.photo-help-button {
+ width: 40px;
+ height: 40px;
+ border: 1px solid rgb(255 255 255 / 13%);
+ border-radius: 12px;
+ background: rgb(26 30 34 / 88%);
+ box-shadow: 0 10px 28px rgb(0 0 0 / 24%);
+ color: #f6f7f8;
+ backdrop-filter: blur(12px);
+}
+.photo-help-button:hover {
+ background: rgb(40 45 51 / 96%);
+ color: white;
+}
+.photo-help-tooltip {
+ width: min(280px, calc(100vw - 32px));
+ padding: 9px 11px;
+ line-height: 1.45;
+ text-wrap: pretty;
+}
+
+.brand {
+ position: absolute;
+ top: 28px;
+ left: 32px;
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ font-size: 13px;
+ font-weight: 760;
+ letter-spacing: -0.01em;
+}
+
+.brand em {
+ color: #77808c;
+ font-style: normal;
+ font-weight: 600;
+}
+.brand-mark {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: #5b7fe5;
+ box-shadow: 0 0 0 3px rgb(91 127 229 / 15%);
+}
+.language-picker {
+ position: absolute;
+ z-index: 5;
+ top: 24px;
+ right: 28px;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ min-height: 34px;
+ padding: 4px 8px 4px 10px;
+ border: 1px solid rgb(255 255 255 / 12%);
+ border-radius: 10px;
+ background: rgb(26 30 34 / 86%);
+ box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
+ backdrop-filter: blur(12px);
+}
+.language-picker > span {
+ font-size: 15px;
+ line-height: 1;
+}
+.language-picker [data-slot='select-trigger'] {
+ width: auto;
+ height: 24px;
+ min-width: 82px;
+ padding: 0 4px;
+ border: 0;
+ background: transparent;
+ box-shadow: none;
+ color: #f6f7f8;
+ font-size: 10px;
+ font-weight: 750;
+}
+.language-picker [data-slot='select-trigger']:focus-visible {
+ border-radius: 5px;
+ box-shadow: 0 0 0 2px #7895ee;
+}
+
+.avatar-wrap {
+ position: relative;
+ z-index: 1;
+ width: min(72vh, 72vw, 680px);
+ aspect-ratio: 1;
+}
+.stage-playback-status {
+ position: absolute;
+ z-index: 6;
+ top: 3%;
+ left: 3%;
+ padding: 9px 11px;
+ border: 1px solid rgb(255 255 255 / 12%);
+ border-radius: 12px;
+ background: rgb(26 30 34 / 86%);
+ box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
+ backdrop-filter: blur(12px);
+ pointer-events: none;
+}
+.stage-playback-status .playback-identity {
+ min-width: 104px;
+ padding: 0;
+}
+.stage-playback-status .playback-identity strong {
+ color: #f6f7f8;
+}
+.stage-playback-status .playback-identity small {
+ color: #8d96a2;
+}
+
+.avatar {
+ display: block;
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+ cursor: grab;
+ touch-action: none;
+}
+.avatar:active {
+ cursor: grabbing;
+}
+
+.export-avatar-summary {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: var(--muted);
+}
+
+.export-avatar-summary > svg {
+ width: 62px;
+ height: 62px;
+ flex: none;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ background: var(--card);
+}
+
+.export-avatar-summary > div {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.export-avatar-summary small,
+.export-format-grid small {
+ color: var(--muted-foreground);
+ font-size: 11px;
+ font-weight: 500;
+}
+
+.export-avatar-summary strong {
+ overflow: hidden;
+ font-size: 16px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.export-format-grid {
+ display: grid;
+ min-width: 0;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.export-format-grid > button {
+ justify-content: flex-start;
+ min-width: 0;
+ height: auto;
+ min-height: 68px;
+ padding: 10px;
+ text-align: left;
+}
+
+.export-format-grid > button[aria-pressed='true'] {
+ border-color: #6f8ff0;
+ background: #edf1ff;
+ box-shadow: 0 0 0 2px rgb(91 127 229 / 14%);
+ color: #3159cb;
+}
+
+.export-format-grid > button > svg {
+ width: 16px;
+ height: 16px;
+}
+
+.export-format-grid > button > span {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 3px;
+}
+.export-format-grid strong,
+.export-format-grid small {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.export-format-grid strong {
+ font-size: 11px;
+}
+.export-format-grid small {
+ font-size: 9px;
+}
+
+.export-animation-header {
+ align-items: center;
+}
+
+.export-animation-header > button {
+ color: #4168d5;
+ font-size: 11px;
+}
+
+.export-animation-grid {
+ align-items: flex-start;
+}
+
+.export-download {
+ width: 100%;
+ min-height: 44px;
+ box-shadow: 0 12px 30px rgb(23 25 29 / 18%);
+}
+.export-panel {
+ width: 100%;
+ min-width: 0;
+}
+.export-accordion-item {
+ min-width: 0;
+ overflow: hidden;
+ border-color: #dcdfe6;
+ box-shadow: 0 4px 14px rgb(24 28 35 / 5%);
+}
+.export-accordion-trigger > span {
+ display: grid;
+ min-width: 0;
+ gap: 5px;
+}
+.export-accordion-trigger strong {
+ color: #343b49;
+ font-size: 12px;
+ font-weight: 850;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+.export-accordion-trigger small {
+ color: #858c99;
+ font-size: 10px;
+ font-weight: 500;
+ line-height: 1.45;
+ white-space: normal;
+}
+.export-accordion-trigger[aria-expanded='true'] > svg {
+ transform: rotate(180deg);
+}
+.export-accordion-content {
+ min-width: 0;
+ transition: height 200ms ease;
+}
+.export-accordion-inner {
+ display: grid;
+ min-width: 0;
+ gap: 10px;
+}
+.export-accordion-inner > * {
+ min-width: 0;
+}
+.export-placeholder {
+ margin: 0;
+ padding: 12px;
+ color: var(--muted-foreground);
+ border: 1px dashed var(--border);
+ border-radius: 12px;
+ background: var(--muted);
+ font-size: 12px;
+ line-height: 1.5;
+}
+.project-transfer-actions {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+.project-transfer-actions > button {
+ min-height: 42px;
+ font-size: 11px;
+}
+.project-import-input {
+ display: none;
+}
+.project-transfer-error {
+ margin: 10px 0 0;
+ color: var(--destructive);
+ font-size: 12px;
+ line-height: 1.45;
+}
+.snapshot-preview-card {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+}
+.snapshot-preview-card > div:last-child {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 3px;
+}
+.snapshot-preview-card small,
+.snapshot-preview-card span,
+.snapshot-background-field small {
+ color: var(--muted-foreground);
+ font-size: 10px;
+}
+.snapshot-preview-card strong {
+ overflow: hidden;
+ font-size: 15px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.snapshot-preview {
+ width: 112px;
+ height: 112px;
+ flex: none;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: white;
+}
+.snapshot-preview.is-transparent {
+ background-color: white;
+ background-image:
+ linear-gradient(45deg, #e7e9ee 25%, transparent 25%),
+ linear-gradient(-45deg, #e7e9ee 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, #e7e9ee 75%),
+ linear-gradient(-45deg, transparent 75%, #e7e9ee 75%);
+ background-position:
+ 0 0,
+ 0 6px,
+ 6px -6px,
+ -6px 0;
+ background-size: 12px 12px;
+}
+.snapshot-preview svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+.snapshot-background-field {
+ align-items: center;
+}
+.snapshot-background-field > [data-slot='select-trigger'] {
+ width: 190px;
+}
+.snapshot-background-field > div:first-child {
+ display: grid;
+ gap: 3px;
+}
+.snapshot-colors {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ margin-top: 14px;
+}
+.snapshot-settings-separator {
+ margin-block: 14px;
+}
+.avatar-head,
+.preview-head {
+ fill: var(--avatar-body-color, #5b7fe5);
+}
+.avatar-eye,
+.preview-eye {
+ fill: var(--avatar-eye-color, #111316);
+}
+.avatar-head,
+.avatar-eye {
+ transition: fill 420ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+.avatar-eye {
+ cursor: pointer;
+}
+.cyan-outline {
+ stroke: #51ddff;
+ stroke-width: 2.6;
+ stroke-linejoin: round;
+ vector-effect: non-scaling-stroke;
+}
+.selection-outline {
+ fill: none;
+ stroke: #51ddff;
+ stroke-width: 2.4;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ pointer-events: none;
+}
+.body-selection-outline {
+ stroke-width: 3;
+ filter: drop-shadow(0 0 3px rgb(81 221 255 / 52%));
+}
+.body-node-gizmo {
+ isolation: isolate;
+}
+.body-gizmo-ring,
+.body-gizmo-axis {
+ fill: none;
+ vector-effect: non-scaling-stroke;
+ touch-action: none;
+ transition:
+ opacity 120ms ease,
+ stroke-width 120ms ease,
+ filter 120ms ease;
+}
+.body-gizmo-hitbox {
+ fill: none;
+ stroke: transparent;
+ stroke-width: 14;
+ vector-effect: non-scaling-stroke;
+ cursor: grab;
+ touch-action: none;
+}
+.body-gizmo-ring {
+ stroke-width: 1.5;
+ opacity: 0.42;
+ cursor: grab;
+}
+.body-gizmo-axis {
+ stroke-width: 2.4;
+ cursor: move;
+}
+.body-gizmo-ring-hitbox:hover + .body-gizmo-ring,
+.body-gizmo-ring.is-active,
+.body-gizmo-axis-hitbox:hover + .body-gizmo-axis,
+.body-gizmo-axis.is-active {
+ stroke-width: 4;
+ opacity: 1;
+ filter: drop-shadow(0 0 2px currentColor);
+}
+.body-gizmo-handle {
+ stroke-width: 1.5;
+ vector-effect: non-scaling-stroke;
+ cursor: move;
+ touch-action: none;
+}
+.body-gizmo-handle.gizmo-x {
+ fill: #46d477;
+}
+.body-gizmo-handle.gizmo-y {
+ fill: #ef5f5f;
+}
+.body-gizmo-handle.gizmo-z {
+ fill: #557bea;
+}
+.body-gizmo-origin {
+ fill: #f8fafc;
+ stroke: #11161d;
+ stroke-width: 1.5;
+ vector-effect: non-scaling-stroke;
+ transition:
+ r 120ms ease,
+ stroke-width 120ms ease,
+ filter 120ms ease;
+}
+.body-gizmo-plane-hitbox {
+ fill: transparent;
+ cursor: move;
+ touch-action: none;
+}
+.body-gizmo-plane-hitbox:hover + .body-gizmo-origin,
+.body-gizmo-origin.is-active {
+ stroke: #51ddff;
+ stroke-width: 3;
+ filter: drop-shadow(0 0 3px rgb(81 221 255 / 72%));
+}
+.body-gizmo-reset {
+ cursor: pointer;
+ outline: none;
+}
+.body-gizmo-reset circle {
+ fill: rgb(17 22 29 / 92%);
+ stroke: #8da7ef;
+ stroke-width: 1.3;
+ vector-effect: non-scaling-stroke;
+}
+.body-gizmo-reset text {
+ fill: #f8fafc;
+ font-size: 8px;
+ font-weight: 800;
+ pointer-events: none;
+ text-anchor: middle;
+}
+.body-gizmo-reset:hover circle,
+.body-gizmo-reset:focus-visible circle {
+ fill: #2c4d9d;
+ stroke: #c6d5ff;
+ stroke-width: 2.5;
+}
+.body-gizmo-label {
+ fill: #f8fafc;
+ font-size: 7px;
+ font-weight: 900;
+ paint-order: stroke;
+ pointer-events: none;
+ stroke: #11161d;
+ stroke-width: 2px;
+ text-anchor: middle;
+}
+.editor-guide {
+ fill: none;
+ stroke: rgb(81 221 255 / 58%);
+ stroke-width: 1.15;
+ stroke-dasharray: 3 3;
+ pointer-events: none;
+}
+.editor-control {
+ cursor: grab;
+ touch-action: none;
+}
+.editor-control:active {
+ cursor: grabbing;
+}
+.editor-handle {
+ fill: #111316;
+ stroke: #51ddff;
+ stroke-width: 2;
+}
+.editor-label {
+ fill: #fff;
+ font-size: 6px;
+ font-weight: 900;
+ text-anchor: middle;
+ pointer-events: none;
+ user-select: none;
+}
+.wire {
+ fill: none;
+ stroke: rgb(255 255 255 / 28%);
+ stroke-width: 1;
+ vector-effect: non-scaling-stroke;
+}
+
+.gizmo-cluster {
+ position: absolute;
+ top: 2%;
+ right: 2%;
+ display: flex;
+ flex-direction: row-reverse;
+ align-items: flex-start;
+ gap: 5px;
+}
+.gizmo {
+ width: 94px;
+ height: 94px;
+ overflow: visible;
+}
+.gizmo-reset {
+ width: 26px;
+ height: 26px;
+ border-color: rgb(255 255 255 / 12%);
+ background: rgb(24 29 36 / 82%);
+ color: #dbe4f3;
+ backdrop-filter: blur(8px);
+}
+.gizmo-reset:hover {
+ background: rgb(46 54 65 / 92%);
+ color: white;
+}
+.gizmo-orbit {
+ fill: none;
+ stroke-width: 2;
+ vector-effect: non-scaling-stroke;
+ cursor: ew-resize;
+ transition:
+ stroke-width 140ms ease,
+ filter 140ms ease;
+ touch-action: none;
+}
+.gizmo-orbit:hover,
+.gizmo-orbit:active {
+ stroke-width: 5;
+ filter: brightness(1.25);
+}
+.gizmo-camera {
+ stroke: #f0ce45;
+}
+.gizmo-x {
+ stroke: #46d477;
+}
+.gizmo-y {
+ stroke: #ef5f5f;
+}
+.gizmo-z {
+ stroke: #557bea;
+}
+.axis-key {
+ position: absolute;
+ right: 4%;
+ bottom: 3%;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ color: #77808c;
+ font-size: 10px;
+ font-weight: 800;
+}
+.axis-key i {
+ display: block;
+ width: 6px;
+ height: 6px;
+ margin-left: 4px;
+ border-radius: 50%;
+}
+.axis-key .x {
+ background: #46d477;
+}
+.axis-key .y {
+ background: #ef5f5f;
+}
+.axis-key .z {
+ background: #557bea;
+}
+
+.stage-help {
+ position: absolute;
+ bottom: 28px;
+ width: min(520px, 82%);
+ margin: 0;
+ color: #77808c;
+ font-size: 12px;
+ line-height: 1.5;
+ text-align: center;
+}
+
+.inspector {
+ height: 100vh;
+ overflow-x: hidden;
+ overflow-y: auto;
+ scrollbar-gutter: stable;
+ padding: 28px 26px 48px;
+ border-left: 1px solid #d9dce3;
+ background: #f5f6f8;
+}
+.inspector.expression-workspace-active,
+.inspector.sequence-workspace-active,
+.inspector.body-workspace {
+ padding-top: 0;
+}
+.inspector.state-player-active {
+ padding-bottom: 128px;
+}
+@keyframes studio-workspace-in {
+ from {
+ opacity: 0;
+ transform: translateX(-10px);
+ }
+}
+@keyframes body-workspace-in {
+ from {
+ opacity: 0;
+ transform: translateX(18px);
+ }
+}
+.studio-workspace {
+ animation: studio-workspace-in 220ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+.body-workspace {
+ animation: body-workspace-in 220ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+@media (prefers-reduced-motion: reduce) {
+ .studio-workspace,
+ .body-workspace {
+ animation: none;
+ }
+}
+.workspace-page {
+ display: flex;
+ min-height: calc(100vh - 76px);
+ flex-direction: column;
+ padding-bottom: 74px;
+}
+.workspace-header {
+ position: sticky;
+ z-index: 8;
+ top: 0;
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ margin-bottom: 18px;
+ padding: 31px 2px 16px;
+ border-bottom: 1px solid #d9dce3;
+ background: linear-gradient(to bottom, #f5f6f8 78%, rgb(245 246 248 / 88%));
+ backdrop-filter: blur(10px);
+}
+.workspace-header > [data-slot='button']:first-child {
+ flex: 0 0 auto;
+ margin-top: 4px;
+ border: 1px solid #dfe2e8;
+ border-radius: 50%;
+ background: white;
+ box-shadow: 0 4px 14px rgb(24 28 35 / 5%);
+}
+.workspace-heading {
+ min-width: 0;
+ flex: 1;
+}
+.workspace-heading h1 {
+ font-size: 25px;
+}
+.workspace-heading > p:last-child {
+ max-width: 360px;
+ margin: 6px 0 0;
+ color: #818792;
+ font-size: 11px;
+ line-height: 1.45;
+}
+.workspace-header-actions {
+ display: flex;
+ flex: 0 0 auto;
+ gap: 8px;
+}
+.workspace-scroll {
+ display: grid;
+ gap: 12px;
+}
+.workspace-footer {
+ position: fixed;
+ z-index: 8;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ width: min(520px, 100vw);
+ box-sizing: border-box;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin: 0;
+ padding: 20px 28px 18px;
+ border-top: 1px solid #d9dce3;
+ background: linear-gradient(to bottom, rgb(245 246 248 / 86%), #f5f6f8 30%);
+ backdrop-filter: blur(10px);
+}
+.workspace-footer > [data-slot='button']:last-child,
+.workspace-footer .dialog-actions-main [data-slot='button'] {
+ min-width: 120px;
+}
+.workspace-footer-secondary {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.inspector-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 22px;
+}
+.eyebrow {
+ margin: 0 0 6px;
+ color: #7185c4;
+ font-size: 10px;
+ font-weight: 850;
+ letter-spacing: 0.11em;
+ text-transform: uppercase;
+}
+h1 {
+ margin: 0;
+ font-size: 30px;
+ letter-spacing: -0.045em;
+}
+h2,
+h3,
+p {
+ margin-top: 0;
+}
+.state-player {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px;
+ border: 1px solid #dde1e9;
+ border-radius: 12px;
+ background: white;
+ box-shadow: 0 5px 18px rgb(24 28 35 / 5%);
+}
+.playback-identity {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: #33834b;
+}
+.state-player .playback-identity {
+ min-width: 102px;
+ padding: 2px 7px;
+}
+.playback-identity.is-paused {
+ color: #8a6a24;
+}
+.playback-identity.is-stopped {
+ color: #7f8692;
+}
+.playback-identity i {
+ width: 6px;
+ height: 6px;
+ flex: 0 0 auto;
+ border-radius: 50%;
+ background: currentColor;
+ box-shadow: 0 0 0 4px color-mix(in srgb, currentColor 10%, transparent);
+}
+.playback-identity > span {
+ display: grid;
+ gap: 1px;
+}
+.playback-identity small {
+ color: #9297a1;
+ font-size: 8px;
+ font-weight: 750;
+ letter-spacing: 0.06em;
+ line-height: 1;
+ text-transform: uppercase;
+}
+.playback-identity strong {
+ max-width: 92px;
+ overflow: hidden;
+ color: #343943;
+ font-size: 10px;
+ line-height: 1.2;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.state-player [data-slot='button'] {
+ width: 48px;
+ height: 48px;
+}
+.state-player svg {
+ width: 32px;
+ height: 32px;
+}
+.state-playback-footer {
+ position: fixed;
+ z-index: 9;
+ right: 0;
+ bottom: 0;
+ width: min(520px, 100vw);
+ box-sizing: border-box;
+ padding: 10px 18px;
+ border-top: 1px solid #d9dce3;
+ background: rgb(250 251 253 / 96%);
+ box-shadow: 0 -10px 30px rgb(24 28 35 / 8%);
+ backdrop-filter: blur(14px);
+}
+.state-playback-expand {
+ position: absolute;
+ top: 0;
+ left: 50%;
+ border-radius: 999px;
+ background: white;
+ box-shadow: 0 4px 14px rgb(24 28 35 / 9%);
+ transform: translate(-50%, -50%);
+}
+.state-playback-bar {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 14px;
+}
+.state-playback-details {
+ display: grid;
+ gap: 14px;
+ padding: 14px 2px 16px;
+ border-bottom: 1px solid #dfe3eb;
+}
+.state-playback-details-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 14px;
+}
+.state-playback-details-header > div {
+ min-width: 0;
+}
+.state-playback-details-header .eyebrow {
+ margin-bottom: 4px;
+}
+.state-playback-details-header h2 {
+ margin: 0;
+ font-size: 17px;
+}
+.state-playback-details-header p:last-child {
+ margin: 5px 0 0;
+ color: #7f8692;
+ font-size: 10px;
+ line-height: 1.45;
+}
+.state-playback-detail-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 7px;
+}
+.state-playback-detail-grid > div {
+ display: grid;
+ min-width: 0;
+ gap: 3px;
+ padding: 10px;
+ border: 1px solid #e1e5ec;
+ border-radius: 11px;
+ background: white;
+}
+.state-playback-detail-grid span,
+.state-playback-detail-grid small {
+ overflow: hidden;
+ color: #858b96;
+ font-size: 8px;
+ font-weight: 700;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.state-playback-detail-grid span {
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+.state-playback-detail-grid strong {
+ font-size: 12px;
+}
+.state-playback-footer.is-expanded .state-playback-bar {
+ padding-top: 10px;
+}
+.state-playback-controls {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ padding-left: 14px;
+ border-left: 1px solid #dfe3eb;
+}
+.state-playback-footer .state-player {
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ box-shadow: none;
+}
+.state-playback-timeline {
+ display: flex;
+ min-width: 0;
+ flex: 1;
+ gap: 6px;
+ overflow-x: auto;
+ padding: 5px 3px 7px;
+ scroll-behavior: smooth;
+ scrollbar-color: #cbd2df transparent;
+ scrollbar-width: thin;
+}
+.state-playback-step {
+ position: relative;
+ width: 58px;
+ height: 58px;
+ flex: 0 0 58px;
+ overflow: hidden;
+ padding: 2px;
+ border-radius: 11px;
+ background: white;
+ box-shadow: 0 3px 10px rgb(24 28 35 / 5%);
+}
+.state-playback-step svg {
+ width: 100%;
+ height: 100%;
+}
+.state-playback-step[aria-pressed='true'] {
+ border-color: #aebff0;
+ background: #edf1ff;
+ box-shadow: 0 0 0 2px rgb(91 127 229 / 16%);
+}
+@property --state-countdown-angle {
+ syntax: '';
+ inherits: false;
+ initial-value: 360deg;
+}
+@keyframes state-playback-countdown {
+ to {
+ --state-countdown-angle: 0deg;
+ }
+}
+.state-playback-progress {
+ position: absolute;
+ bottom: 5px;
+ left: 5px;
+ width: 16px;
+ height: 16px;
+ border: 2px solid white;
+ border-radius: 50%;
+ background: conic-gradient(#4f72d8 var(--state-countdown-angle), white 0);
+ box-shadow: 0 1px 4px rgb(24 28 35 / 22%);
+ animation-name: state-playback-countdown;
+ animation-timing-function: linear;
+ animation-fill-mode: forwards;
+ pointer-events: none;
+}
+.body-workspace-header [data-slot='button'] {
+ align-self: center;
+ cursor: pointer;
+}
+.body-workspace-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ margin-bottom: 18px;
+ padding: 31px 2px 16px;
+ border-bottom: 1px solid #d9dce3;
+}
+.avatar-name-input {
+ width: min(330px, 100%);
+ margin: 0;
+ padding: 1px 5px;
+ border: 1px solid transparent;
+ border-radius: 7px;
+ outline: 0;
+ background: transparent;
+ color: #17191d;
+ font-size: 26px;
+ font-weight: 800;
+ letter-spacing: -0.045em;
+}
+.avatar-name-input:hover {
+ border-color: #d9dce3;
+ background: white;
+}
+.avatar-name-input:focus {
+ border-color: #7895ee;
+ background: white;
+ box-shadow: 0 0 0 3px rgb(95 131 232 / 12%);
+}
+.body-workspace-header p:not(.eyebrow) {
+ margin: 6px 0 0;
+ color: #818792;
+ font-size: 11px;
+ line-height: 1.45;
+}
+.body-workspace-header [data-slot='button']:disabled {
+ cursor: not-allowed;
+ opacity: 0.4;
+}
+.avatar-shelf {
+ position: relative;
+ margin-bottom: 12px;
+ padding: 20px;
+ border: 1px solid #dcdfe6;
+ border-radius: 16px;
+ background: white;
+ box-shadow: 0 4px 14px rgb(24 28 35 / 5%);
+}
+.avatar-shelf-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ margin-bottom: 8px;
+}
+.avatar-shelf-heading strong {
+ font-size: 11px;
+}
+.avatar-shelf-heading span {
+ color: #8d929c;
+ font-size: 8px;
+}
+.avatar-shelf .avatar-grid {
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 6px;
+}
+.avatar-shelf .avatar-card,
+.avatar-shelf .avatar-add {
+ height: 74px;
+ min-height: 74px;
+ border-radius: 10px;
+}
+.avatar-sort-item {
+ min-width: 0;
+ will-change: transform;
+}
+.avatar-sort-item .avatar-card {
+ width: 100%;
+}
+.avatar-sort-item[data-dragging='true'] .avatar-card {
+ border-style: dashed;
+ box-shadow: none;
+}
+.avatar-shelf .avatar-card {
+ flex-direction: column;
+ align-items: stretch;
+ cursor: grab;
+}
+.avatar-shelf .avatar-card:active {
+ cursor: grabbing;
+}
+.avatar-shelf .avatar-card svg {
+ height: 48px;
+ flex: 0 1 48px;
+}
+.avatar-shelf .avatar-card > span {
+ min-height: 12px;
+ padding: 0 3px;
+ font-size: 8px;
+}
+.tabs {
+ position: sticky;
+ z-index: 5;
+ top: 12px;
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 5px;
+ margin-bottom: 16px;
+ padding: 5px;
+ backdrop-filter: blur(14px);
+}
+.state-buttons button,
+.button-row button,
+.reset {
+ min-height: 36px;
+ font-size: 11px;
+}
+.color-field {
+ display: grid;
+ gap: 8px;
+ color: #3d424b;
+ font-size: 10px;
+ font-weight: 780;
+}
+.color-control {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ min-height: 42px;
+ padding: 5px 9px 5px 5px;
+ border: 1px solid #dcdfe6;
+ border-radius: 10px;
+ background: #f8f9fb;
+}
+.color-swatch {
+ width: 38px;
+ height: 30px;
+ padding: 2px;
+ border-radius: 7px;
+ cursor: pointer;
+}
+.color-control .color-value {
+ width: 86px;
+ height: 28px;
+ padding: 0 6px;
+ border: 0;
+ background: transparent;
+ box-shadow: none;
+ color: #656c77;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 10px;
+ letter-spacing: 0.04em;
+}
+.ambient-motion-field {
+ grid-template-columns: minmax(0, 1fr) 170px;
+}
+.ambient-motion-field [data-slot='select-trigger'] {
+ height: 34px;
+ padding: 0 30px 0 10px;
+ border: 1px solid #d8dce5;
+ border-radius: 8px;
+ outline: none;
+ background: #fff;
+ color: #272b33;
+ font-size: 12px;
+}
+.ambient-motion-field [data-slot='select-trigger']:focus-visible {
+ border-color: #7897f4;
+ box-shadow: 0 0 0 3px rgb(91 127 229 / 14%);
+}
+.field-help {
+ margin: 9px 0 0;
+ color: #858a95;
+ font-size: 11px;
+ line-height: 1.45;
+}
+.color-panel {
+ position: relative;
+}
+.inherit-colors {
+ position: absolute;
+ top: 14px;
+ right: 14px;
+}
+.position-spacing {
+ margin-top: 14px;
+ padding-top: 12px;
+ border-top: 1px solid #eceef2;
+}
+.panel-inline-subtitle {
+ margin: 4px 0 0;
+ color: #858a95;
+ font-size: 10px;
+ font-weight: 500;
+ line-height: 1.45;
+}
+
+.panel-stack {
+ display: grid;
+ gap: 14px;
+}
+.control-section {
+ display: grid;
+ gap: 14px;
+ padding: 20px;
+ border: 1px solid #dcdfe6;
+ border-radius: 16px;
+ background: white;
+ box-shadow: 0 4px 14px rgb(24 28 35 / 5%);
+}
+.control-section-header {
+ padding: 4px 5px 2px;
+}
+.control-section-header h2 {
+ margin: 0;
+ color: #343b49;
+ font-size: 12px;
+ font-weight: 850;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+.control-section-header p {
+ margin: 5px 0 0;
+ color: #858c99;
+ font-size: 10px;
+ line-height: 1.45;
+}
+.control-section-separator {
+ opacity: 0.7;
+}
+.control-section-content {
+ display: grid;
+ gap: 10px;
+}
+.control-section .panel {
+ border-color: #e0e3e9;
+}
+.control-section-compact {
+ padding: 20px;
+ border-radius: 16px;
+}
+.control-section-compact .control-section-header {
+ padding-inline: 3px;
+}
+.body-tree {
+ display: grid;
+ gap: 6px;
+}
+.body-tree > button {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ height: auto;
+ min-height: 46px;
+ padding: 10px 12px;
+ text-align: left;
+ white-space: normal;
+}
+.body-tree > button[aria-pressed='true'] {
+ border-color: #7796ec;
+ background: #edf1ff;
+ color: #3f63c6;
+}
+.body-tree > button span:last-child {
+ display: grid;
+ min-width: 0;
+ gap: 2px;
+}
+.body-tree strong,
+.body-tree small {
+ line-height: 1.25;
+}
+.body-tree small {
+ color: #858a95;
+ font-size: 9px;
+}
+.body-node-icon {
+ display: grid;
+ width: 34px;
+ height: 34px;
+ flex: 0 0 34px;
+ place-items: center;
+ color: #7c8492;
+}
+.body-node-icon-primary {
+ width: 40px;
+ height: 40px;
+ flex-basis: 40px;
+}
+.body-node-icon svg {
+ width: 100%;
+ height: 100%;
+}
+.body-node-icon svg path {
+ fill: currentColor;
+}
+.body-add {
+ display: grid;
+ gap: 7px;
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid #eceef2;
+ color: #858a95;
+ font-size: 10px;
+ font-weight: 750;
+}
+.body-add > div {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ gap: 6px;
+}
+.body-node-actions button {
+ font-size: 9px;
+}
+.body-add .body-add-card {
+ height: 62px;
+ min-height: 62px;
+ padding: 4px 2px;
+ border-radius: 10px;
+ font-size: 8px;
+}
+.body-add .body-add-card svg {
+ width: 30px;
+ height: 30px;
+}
+.body-node-editor {
+ margin-top: 14px;
+ padding: 13px;
+ border: 1px solid #dde3f2;
+ border-radius: 13px;
+ background: #fafbff;
+}
+.body-node-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+.body-node-actions > div {
+ display: flex;
+ gap: 6px;
+}
+.body-gizmo-help {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 10px 0 0;
+ color: #747c89;
+ font-size: 9px;
+ line-height: 1.4;
+}
+.body-transform-grid {
+ display: grid;
+ margin-top: 12px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px;
+}
+.body-transform-grid h3 {
+ margin: 0 0 5px;
+ font-size: 10px;
+}
+.body-transform-grid .numeric-field {
+ grid-template-columns: 42px minmax(0, 1fr);
+}
+.body-transform-grid .scrub-label {
+ grid-column: 1;
+}
+.panel {
+ gap: 14px;
+ padding: 20px;
+ border: 1px solid #dcdfe6;
+ border-radius: 16px;
+ background: white;
+ box-shadow: 0 4px 14px rgb(24 28 35 / 5%);
+}
+.section-description {
+ margin: -2px 3px 2px;
+ color: #747c89;
+ font-size: 10px;
+ line-height: 1.55;
+}
+.panel.compact {
+ padding-top: 16px;
+}
+.surface-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 7px;
+}
+.surface-card {
+ display: grid;
+ min-width: 0;
+ min-height: 82px;
+ padding: 6px 4px 7px;
+ place-items: center;
+ color: #767c87;
+ cursor: pointer;
+ font-size: 9px;
+ font-weight: 780;
+ transition:
+ border-color 140ms ease,
+ background 140ms ease,
+ color 140ms ease,
+ transform 140ms ease;
+}
+.surface-card:hover {
+ border-color: #c7cfdf;
+ transform: translateY(-1px);
+}
+.surface-card[aria-pressed='true'] {
+ border-color: #7796ec;
+ background: #edf1ff;
+ color: #3f63c6;
+ box-shadow: 0 0 0 2px rgb(91 127 229 / 12%);
+}
+.surface-card svg {
+ width: 48px;
+ height: 48px;
+}
+.surface-card svg path {
+ fill: currentColor;
+}
+.surface-fields {
+ display: grid;
+ margin-top: 14px;
+ padding-top: 10px;
+ border-top: 1px solid #eceef2;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ column-gap: 14px;
+}
+.surface-fields .numeric-field {
+ grid-template-columns: minmax(0, 1fr) 72px;
+}
+.surface-fields .numeric-field + .numeric-field {
+ border-top: 0;
+}
+.panel-title h2,
+.panel-title h3,
+.panel-inline-title h2,
+.panel-inline-title h3,
+.preset-header h2,
+.state-detail h2 {
+ margin: 0;
+ font-size: 15px;
+ letter-spacing: -0.015em;
+}
+.panel-title {
+ gap: 5px;
+ margin-bottom: 17px;
+ padding: 0;
+}
+.panel-title [data-slot='card-title'] {
+ line-height: inherit;
+}
+.panel-title [data-slot='card-description'] {
+ font-size: 11px;
+ line-height: 1.45;
+}
+.panel-title p {
+ margin: 5px 0 17px;
+ color: #858a95;
+ font-size: 11px;
+ line-height: 1.45;
+}
+.panel-inline-title,
+.preset-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+.eye-columns {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px;
+}
+.eye-column h3 {
+ margin: 0 0 5px 4px;
+ color: #858a95;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+.eye-columns .numeric-field {
+ grid-template-columns: minmax(0, 1fr) 72px;
+}
+.eye-columns .numeric-field + .numeric-field {
+ border-top: 0;
+}
+.preset-header > span {
+ color: #8a8f99;
+ font-size: 10px;
+}
+
+.numeric-field {
+ display: grid;
+ align-items: center;
+ grid-template-columns: minmax(112px, 0.85fr) minmax(100px, 1fr) 72px;
+ gap: 10px;
+ min-height: 39px;
+}
+.numeric-field + .numeric-field {
+ border-top: 1px solid #f0f1f4;
+}
+.scrub-label {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ justify-content: space-between;
+ gap: 5px;
+ padding: 8px 4px;
+ cursor: ew-resize;
+ touch-action: none;
+ font-size: 11px;
+ font-weight: 720;
+ text-align: left;
+}
+.scrub-label span:first-child {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.scrub-icon {
+ color: #7891dc;
+ font-size: 14px;
+ opacity: 0.65;
+}
+.scrub-label:hover .scrub-icon,
+.scrub-label:active .scrub-icon {
+ opacity: 1;
+}
+.range {
+ width: 100%;
+ accent-color: #5b7fe5;
+ cursor: pointer;
+}
+.number-shell {
+ display: flex;
+ align-items: center;
+ overflow: hidden;
+ border: 1px solid #dcdfe6;
+ border-radius: 8px;
+ background: #f7f8fa;
+}
+.number-shell:focus-within {
+ border-color: #7592e9;
+ box-shadow: 0 0 0 3px rgb(91 127 229 / 12%);
+}
+.number-shell input {
+ width: 100%;
+ min-width: 0;
+ padding: 7px 0 7px 8px;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ font-size: 10px;
+ font-variant-numeric: tabular-nums;
+}
+.number-shell span {
+ padding-right: 7px;
+ color: #8a8f99;
+ font-size: 9px;
+}
+.number-shell input::-webkit-inner-spin-button {
+ display: none;
+}
+
+.link-button,
+.icon-button {
+ display: grid;
+ width: 32px;
+ height: 32px;
+ padding: 7px;
+ place-items: center;
+}
+.link-button svg {
+ width: 16px;
+ fill: none;
+ stroke: currentColor;
+ stroke-linecap: round;
+ stroke-width: 1.8;
+}
+.link-button[aria-pressed='true'] {
+ border-color: #b8c7f3;
+ background: #edf1ff;
+ color: #5075df;
+}
+.switch {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: 12px;
+ padding-top: 13px;
+ border-top: 1px solid #eceef2;
+ font-size: 11px;
+ font-weight: 720;
+}
+.switch input {
+ accent-color: #5b7fe5;
+}
+.reset {
+ width: 100%;
+ margin-top: 14px;
+}
+
+.expression-grid {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 8px;
+}
+.expression-sort-item,
+.state-sort-item {
+ min-width: 0;
+}
+.expression-sort-item > *,
+.state-sort-item > * {
+ width: 100%;
+}
+.expression-card,
+.expression-add {
+ position: relative;
+ min-height: 76px;
+ overflow: hidden;
+ padding: 3px;
+ cursor: pointer;
+}
+.avatar-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px;
+}
+.avatar-card,
+.avatar-add {
+ position: relative;
+ min-height: 132px;
+ overflow: hidden;
+ padding: 7px;
+ cursor: pointer;
+}
+.avatar-card svg {
+ display: block;
+ width: 100%;
+ height: 105px;
+}
+.avatar-card > span {
+ display: block;
+ overflow: hidden;
+ padding: 5px 5px 3px;
+ font-size: 10px;
+ font-weight: 850;
+ text-align: left;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.avatar-card[aria-pressed='true'] {
+ border-color: #5b7fe5;
+ box-shadow: 0 0 0 2px rgb(91 127 229 / 18%);
+}
+.creation-card {
+ display: grid;
+ place-items: center;
+ border-style: dashed;
+ color: #5b7fe5;
+}
+.creation-card svg {
+ width: 22px;
+ height: 22px;
+}
+.expression-card svg {
+ display: block;
+ width: 100%;
+ height: 64px;
+}
+.expression-card > span {
+ position: absolute;
+ right: 5px;
+ bottom: 4px;
+ padding: 2px 4px;
+ border-radius: 5px;
+ background: rgb(255 255 255 / 78%);
+ font-size: 8px;
+ font-weight: 850;
+}
+.expression-card[aria-pressed='true'] {
+ border-color: #5b7fe5;
+ box-shadow: 0 0 0 2px rgb(91 127 229 / 18%);
+}
+.expression-add {
+ border-radius: 10px;
+}
+.preview-head {
+ fill: #5b7fe5;
+}
+.preview-eye {
+ fill: #111316;
+}
+.button-row {
+ display: flex;
+ gap: 8px;
+ margin-top: 14px;
+}
+.button-row button {
+ flex: 1;
+}
+.state-groups {
+ display: grid;
+ gap: 15px;
+}
+.state-groups strong {
+ display: block;
+ margin-bottom: 7px;
+ color: #888d97;
+ font-size: 9px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+.state-buttons {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 8px;
+}
+.state-buttons .state-card > span {
+ max-width: calc(100% - 10px);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.state-buttons .state-card {
+ height: 76px;
+ min-height: 76px;
+}
+.state-buttons .state-card[aria-pressed='true'] {
+ border-color: #aebff0;
+ background: #edf1ff;
+ color: #4f72d8;
+}
+.state-detail p {
+ margin: 7px 0 14px;
+ color: #7f848e;
+ font-size: 11px;
+ line-height: 1.5;
+}
+.state-detail-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+.state-detail-header > div {
+ min-width: 0;
+}
+.state-step-preview {
+ display: grid;
+ gap: 4px;
+}
+.state-step-preview > small {
+ color: #8a909b;
+ font-size: 8px;
+ font-weight: 750;
+ text-align: center;
+}
+.state-expression-section,
+.state-blink-section {
+ padding-top: 16px;
+ border-top: 1px solid #eceef2;
+}
+.state-blink-section {
+ margin-top: 18px;
+}
+.state-section-heading {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 11px;
+}
+.state-section-heading h3 {
+ margin: 0;
+ font-size: 12px;
+}
+.state-section-heading p {
+ margin: 4px 0 0;
+ font-size: 10px;
+}
+.state-section-heading > span {
+ flex: 0 0 auto;
+ padding: 4px 7px;
+ border-radius: 999px;
+ background: #eef1f7;
+ color: #6f7785;
+ font-size: 8px;
+ font-weight: 800;
+ text-transform: uppercase;
+}
+.state-expression-grid {
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+}
+.state-logic-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+}
+.state-logic-grid > div {
+ display: grid;
+ gap: 3px;
+ padding: 11px;
+ border: 1px solid #e1e4ea;
+ border-radius: 11px;
+ background: #f7f8fa;
+}
+.state-logic-grid span,
+.state-logic-grid small {
+ color: #858b96;
+ font-size: 8px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.sequence-workspace-scroll {
+ padding-bottom: 6px;
+}
+.sequence-workspace [data-slot='card'] {
+ gap: 14px;
+}
+.sequence-timeline {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 3px;
+}
+.sequence-step {
+ position: relative;
+ flex: 0 0 90px;
+ cursor: grab;
+}
+.sequence-step:active {
+ cursor: grabbing;
+}
+.sequence-step > [data-slot='button'] {
+ position: relative;
+ display: block;
+ width: 100%;
+ height: 94px;
+ overflow: hidden;
+ padding: 4px;
+}
+.sequence-step > [data-slot='button'][aria-pressed='true'] {
+ border-color: #5b7fe5;
+ box-shadow: 0 0 0 2px rgb(91 127 229 / 18%);
+}
+.sequence-step svg:not(.sequence-grip) {
+ display: block;
+ width: 100%;
+ height: 76px;
+}
+.sequence-step .sequence-grip {
+ position: absolute;
+ z-index: 2;
+ top: 6px;
+ left: 5px;
+ width: 13px;
+ height: 13px;
+ padding: 2px;
+ border-radius: 4px;
+ background: rgb(255 255 255 / 82%);
+ color: #7f8794;
+}
+.sequence-step span,
+.sequence-step small {
+ position: absolute;
+ z-index: 2;
+ bottom: 5px;
+ padding: 2px 4px;
+ border-radius: 5px;
+ background: rgb(255 255 255 / 82%);
+ font-size: 8px;
+ font-weight: 850;
+}
+.sequence-step span {
+ right: 5px;
+}
+.sequence-step small {
+ left: 5px;
+ color: #727a87;
+}
+.sequence-empty {
+ margin: 0;
+ padding: 24px 12px;
+ border: 1px dashed #d6dae3;
+ border-radius: 10px;
+ color: #858b96;
+ font-size: 11px;
+ text-align: center;
+}
+.sequence-step-settings {
+ display: grid;
+ gap: 10px;
+ margin-top: 4px;
+ padding-top: 14px;
+ border-top: 1px solid #eceef2;
+}
+
+.avatar-card[aria-pressed='true'],
+.expression-card[aria-pressed='true'],
+.sequence-step > [data-slot='button'][aria-pressed='true'],
+.surface-card[aria-pressed='true'],
+.body-tree > button[aria-pressed='true'] {
+ border-color: #aebff0;
+ background: #edf1ff;
+ color: #4f72d8;
+ box-shadow: 0 0 0 2px rgb(91 127 229 / 16%);
+}
+.sequence-transition-field {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 170px;
+}
+.sequence-expression-library {
+ max-height: 264px;
+ overflow-y: auto;
+ padding: 2px;
+}
+.state-logic-grid strong {
+ color: #303640;
+ font-size: 13px;
+}
+.state-logic-grid small {
+ font-size: 7px;
+ font-weight: 650;
+ letter-spacing: 0;
+ text-transform: none;
+}
+
+.dialog-fields {
+ display: grid;
+ gap: 12px;
+}
+.dialog-group {
+ padding: 13px;
+ border: 1px solid #e0e2e7;
+ border-radius: 14px;
+ background: white;
+}
+.dialog-group h3 {
+ margin: 0 0 7px;
+ font-size: 11px;
+}
+.dialog-group .panel-inline-title {
+ margin-bottom: 7px;
+}
+.dialog-group .panel-inline-title h3 {
+ margin: 0;
+}
+.dialog-actions-main {
+ display: flex;
+ margin-left: auto;
+ gap: 8px;
+}
+
+@media (max-width: 980px) {
+ .studio {
+ grid-template-columns: 1fr;
+ }
+ .stage-column {
+ position: relative;
+ height: min(72vh, 680px);
+ min-height: 480px;
+ }
+ .inspector {
+ border-top: 1px solid #d9dce3;
+ border-left: 0;
+ }
+ .avatar-wrap {
+ width: min(62vh, 82vw);
+ }
+}
+
+@media (max-width: 600px) {
+ .stage-column {
+ min-height: 420px;
+ padding: 18px;
+ }
+ .photo-capture-bar {
+ bottom: 48px;
+ }
+ .photo-capture-button {
+ min-width: 168px;
+ }
+ .brand {
+ top: 18px;
+ left: 20px;
+ }
+ .inspector {
+ padding: 22px 14px 40px;
+ }
+ .numeric-field {
+ grid-template-columns: 100px minmax(76px, 1fr) 66px;
+ gap: 7px;
+ }
+ .expression-grid {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+ .avatar-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .avatar-shelf .avatar-grid {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ padding-right: 0;
+ }
+ .avatar-edit {
+ position: static;
+ width: 100%;
+ min-height: 36px;
+ margin-top: 8px;
+ }
+ .surface-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+ .workspace-header-actions .state-player > span {
+ display: none;
+ }
+}
diff --git a/src/surfaces.ts b/src/surfaces.ts
new file mode 100644
index 0000000..98295fd
--- /dev/null
+++ b/src/surfaces.ts
@@ -0,0 +1,665 @@
+import type { Point3 } from './geometry'
+
+export type SurfaceType =
+ 'sphere' | 'mickey' | 'cursor' | 'cube' | 'capsule' | 'cylinder' | 'cone' | 'diamond'
+
+export type SurfaceConfig = {
+ type: SurfaceType
+ width: number
+ height: number
+ depth: number
+ roundness: number
+ morphRoundness?: number
+ tipRoundness?: number
+ baseRoundness?: number
+}
+
+export type SurfaceSample = {
+ point: Point3
+ normal: Point3
+}
+
+export const surfacePresets: Record = {
+ sphere: { type: 'sphere', width: 240, height: 240, depth: 240, roundness: 1 },
+ mickey: { type: 'mickey', width: 220, height: 210, depth: 145, roundness: 1 },
+ cursor: { type: 'cursor', width: 175, height: 260, depth: 145, roundness: 0 },
+ cube: { type: 'cube', width: 245, height: 245, depth: 220, roundness: 0 },
+ capsule: { type: 'capsule', width: 205, height: 270, depth: 205, roundness: 1 },
+ cylinder: {
+ type: 'cylinder',
+ width: 235,
+ height: 250,
+ depth: 215,
+ roundness: 0.45,
+ morphRoundness: 0,
+ },
+ cone: {
+ type: 'cone',
+ width: 250,
+ height: 265,
+ depth: 225,
+ roundness: 0,
+ morphRoundness: 0,
+ tipRoundness: 0.55,
+ baseRoundness: 0.45,
+ },
+ diamond: { type: 'diamond', width: 235, height: 260, depth: 215, roundness: 0 },
+}
+
+export const surfaceLabels: Record = {
+ sphere: 'Sphère',
+ mickey: 'Mickey',
+ cursor: 'Curseur',
+ cube: 'Cube',
+ capsule: 'Capsule',
+ cylinder: 'Cylindre',
+ cone: 'Cône',
+ diamond: 'Diamant',
+}
+
+const signedPower = (value: number, exponent: number) =>
+ Math.sign(value) * Math.abs(value) ** exponent
+
+const superellipsoid = (
+ longitude: number,
+ latitude: number,
+ width: number,
+ height: number,
+ depth: number,
+ verticalExponent: number,
+ horizontalExponent: number
+): Point3 => {
+ const latitudeCosine = signedPower(Math.cos(latitude), verticalExponent)
+ return [
+ (width / 2) * latitudeCosine * signedPower(Math.sin(longitude), horizontalExponent),
+ (height / 2) * signedPower(Math.sin(latitude), verticalExponent),
+ (depth / 2) * latitudeCosine * signedPower(Math.cos(longitude), horizontalExponent),
+ ]
+}
+
+const capsule = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => {
+ const radiusX = config.width / 2
+ const radiusZ = config.depth / 2
+ const capRadius = Math.min(radiusX, config.height / 2)
+ const straightHalf = Math.max(0, (config.height - capRadius * 2) / 2)
+ const meridianLength = straightHalf * 2 + Math.PI * capRadius
+ const distance = ((latitude + Math.PI / 2) / Math.PI) * meridianLength
+ let radial = radiusX
+ let y = 0
+
+ if (distance < (Math.PI * capRadius) / 2) {
+ const angle = -Math.PI / 2 + distance / capRadius
+ radial = radiusX * Math.cos(angle)
+ y = -straightHalf + capRadius * Math.sin(angle)
+ } else if (distance <= (Math.PI * capRadius) / 2 + straightHalf * 2) {
+ y = -straightHalf + distance - (Math.PI * capRadius) / 2
+ } else {
+ const angle = (distance - (Math.PI * capRadius) / 2 - straightHalf * 2) / capRadius
+ radial = radiusX * Math.cos(angle)
+ y = straightHalf + capRadius * Math.sin(angle)
+ }
+
+ const depthScale = radiusX ? radiusZ / radiusX : 1
+ return [radial * Math.sin(longitude), y, radial * depthScale * Math.cos(longitude)]
+}
+
+const clampRoundness = (roundness: number | undefined) => Math.max(0, Math.min(2, roundness ?? 0))
+
+const diamondExponent = (config: SurfaceConfig) => 1 + clampRoundness(config.roundness) / 2
+
+const MIN_CUBE_SURFACE_POWER = 0.04
+
+const cubeExponent = (config: SurfaceConfig) => {
+ if (config.roundness <= 0) return Infinity
+ // The implicit superellipsoid power moves from an almost-flat cube to an ellipsoid.
+ const surfacePower =
+ MIN_CUBE_SURFACE_POWER + (clampRoundness(config.roundness) / 2) * (1 - MIN_CUBE_SURFACE_POWER)
+ return 2 / surfacePower
+}
+
+const lpSurface = (
+ config: SurfaceConfig,
+ longitude: number,
+ latitude: number,
+ exponent: number
+): Point3 => {
+ const sphereX = Math.cos(latitude) * Math.sin(longitude)
+ const sphereY = Math.sin(latitude)
+ const sphereZ = Math.cos(latitude) * Math.cos(longitude)
+ const length = Number.isFinite(exponent)
+ ? (Math.abs(sphereX) ** exponent +
+ Math.abs(sphereY) ** exponent +
+ Math.abs(sphereZ) ** exponent) **
+ (1 / exponent) || 1
+ : Math.max(Math.abs(sphereX), Math.abs(sphereY), Math.abs(sphereZ)) || 1
+ return [
+ (config.width / 2) * (sphereX / length),
+ (config.height / 2) * (sphereY / length),
+ (config.depth / 2) * (sphereZ / length),
+ ]
+}
+
+const diamond = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => {
+ return lpSurface(config, longitude, latitude, diamondExponent(config))
+}
+
+const cube = (config: SurfaceConfig, longitude: number, latitude: number): Point3 =>
+ lpSurface(config, longitude, latitude, cubeExponent(config))
+
+const MAX_CONE_TIP_FRACTION = 0.24
+const MAX_CONE_BASE_FRACTION = 0.2
+const MAX_CYLINDER_EDGE_FRACTION = 0.22
+
+type RadialProfile = {
+ radiusScale: number
+ verticalProgress: number
+}
+
+const morphProgress = (config: SurfaceConfig) => clampRoundness(config.morphRoundness) / 2
+
+const morphProfileToEllipsoid = (
+ config: SurfaceConfig,
+ progress: number,
+ profile: RadialProfile
+): RadialProfile => {
+ const amount = morphProgress(config)
+ const clampedProgress = Math.max(0, Math.min(1, progress))
+ const ellipsoidRadius = Math.sin(clampedProgress * Math.PI)
+ const ellipsoidVerticalProgress = (1 - Math.cos(clampedProgress * Math.PI)) / 2
+ return {
+ radiusScale: profile.radiusScale + (ellipsoidRadius - profile.radiusScale) * amount,
+ verticalProgress:
+ profile.verticalProgress + (ellipsoidVerticalProgress - profile.verticalProgress) * amount,
+ }
+}
+
+const cubic = (
+ start: number,
+ firstControl: number,
+ secondControl: number,
+ end: number,
+ progress: number
+) => {
+ const inverse = 1 - progress
+ return (
+ inverse ** 3 * start +
+ 3 * inverse * inverse * progress * firstControl +
+ 3 * inverse * progress * progress * secondControl +
+ progress ** 3 * end
+ )
+}
+
+const coneRounding = (config: SurfaceConfig) => ({
+ tipFraction: (config.tipRoundness ?? 0) * MAX_CONE_TIP_FRACTION,
+ baseFraction: (config.baseRoundness ?? 0) * MAX_CONE_BASE_FRACTION,
+})
+
+/** Cylinder half-profile with a quarter-round transition at both caps. */
+const cylinderProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => {
+ const clampedProgress = Math.max(0, Math.min(1, progress))
+ const edgeFraction = config.roundness * MAX_CYLINDER_EDGE_FRACTION
+ if (edgeFraction <= 0) {
+ return {
+ radiusScale: 1,
+ verticalProgress: (Math.sin((clampedProgress - 0.5) * Math.PI) + 1) / 2,
+ }
+ }
+
+ if (clampedProgress < edgeFraction) {
+ const angle = -Math.PI / 2 + (clampedProgress / edgeFraction) * (Math.PI / 2)
+ return {
+ radiusScale: 1 - edgeFraction + edgeFraction * Math.cos(angle),
+ verticalProgress: (edgeFraction + edgeFraction * Math.sin(angle)) / 2,
+ }
+ }
+
+ if (clampedProgress > 1 - edgeFraction) {
+ const angle = ((clampedProgress - (1 - edgeFraction)) / edgeFraction) * (Math.PI / 2)
+ return {
+ radiusScale: 1 - edgeFraction + edgeFraction * Math.cos(angle),
+ verticalProgress: 1 - edgeFraction / 2 + (edgeFraction * Math.sin(angle)) / 2,
+ }
+ }
+
+ const middleProgress = (clampedProgress - edgeFraction) / (1 - edgeFraction * 2)
+ return {
+ radiusScale: 1,
+ verticalProgress: edgeFraction / 2 + middleProgress * (1 - edgeFraction),
+ }
+}
+
+const morphedCylinderProfileAt = (config: SurfaceConfig, progress: number) =>
+ morphProfileToEllipsoid(config, progress, cylinderProfileAt(config, progress))
+
+const radiusScaleAtVerticalProgress = (
+ config: SurfaceConfig,
+ verticalProgress: number,
+ profileAt: (config: SurfaceConfig, progress: number) => RadialProfile
+) => {
+ const progress = Math.max(0, Math.min(1, verticalProgress))
+ let lower = 0
+ let upper = 1
+ for (let iteration = 0; iteration < 14; iteration += 1) {
+ const candidate = (lower + upper) / 2
+ if (profileAt(config, candidate).verticalProgress < progress) lower = candidate
+ else upper = candidate
+ }
+ return profileAt(config, (lower + upper) / 2).radiusScale
+}
+
+/** Rounded half-profile revolved around the cone's vertical axis. */
+const coneProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => {
+ const clampedProgress = Math.max(0, Math.min(1, progress))
+ const { tipFraction, baseFraction } = coneRounding(config)
+
+ if (baseFraction > 0 && clampedProgress < baseFraction) {
+ const curveProgress = clampedProgress / baseFraction
+ return {
+ radiusScale: cubic(
+ 1 - baseFraction,
+ 1,
+ 1 - baseFraction / 2,
+ 1 - baseFraction,
+ curveProgress
+ ),
+ verticalProgress: cubic(0, 0, baseFraction / 2, baseFraction, curveProgress),
+ }
+ }
+
+ if (tipFraction > 0 && clampedProgress > 1 - tipFraction) {
+ const curveProgress = (clampedProgress - (1 - tipFraction)) / tipFraction
+ return {
+ radiusScale: cubic(tipFraction, tipFraction / 2, tipFraction / 4, 0, curveProgress),
+ verticalProgress: cubic(1 - tipFraction, 1 - tipFraction / 2, 1, 1, curveProgress),
+ }
+ }
+
+ return {
+ radiusScale: 1 - clampedProgress,
+ verticalProgress: clampedProgress,
+ }
+}
+
+const morphedConeProfileAt = (config: SurfaceConfig, progress: number) =>
+ morphProfileToEllipsoid(config, progress, coneProfileAt(config, progress))
+
+export const cursorLayout = (config: SurfaceConfig) => {
+ const coneHeight = config.height * 0.36
+ const bodyHeight = config.height - coneHeight
+ return {
+ coneApexY: -config.height / 2,
+ coneBaseY: -config.height / 2 + coneHeight,
+ bodyHeight,
+ bodyCenterY: config.height / 2 - bodyHeight / 2,
+ bodyWidth: config.width * 0.54,
+ bodyDepth: config.depth * 0.62,
+ }
+}
+
+export const surfacePointAt = (
+ config: SurfaceConfig,
+ longitude: number,
+ latitude: number
+): Point3 => {
+ const { width, height, depth } = config
+ switch (config.type) {
+ case 'sphere':
+ case 'mickey':
+ return superellipsoid(longitude, latitude, width, height, depth, 1, 1)
+ case 'cube':
+ return cube(config, longitude, latitude)
+ case 'cylinder': {
+ const progress = (latitude + Math.PI / 2) / Math.PI
+ const profile = morphedCylinderProfileAt(config, progress)
+ return [
+ (width / 2) * profile.radiusScale * Math.sin(longitude),
+ -height / 2 + height * profile.verticalProgress,
+ (depth / 2) * profile.radiusScale * Math.cos(longitude),
+ ]
+ }
+ case 'cursor': {
+ const layout = cursorLayout(config)
+ const progress = (latitude + Math.PI / 2) / Math.PI
+ const bodyConfig = {
+ ...config,
+ width: layout.bodyWidth,
+ height: layout.bodyHeight,
+ depth: layout.bodyDepth,
+ }
+ const profile = cylinderProfileAt(bodyConfig, progress)
+ return [
+ (layout.bodyWidth / 2) * profile.radiusScale * Math.sin(longitude),
+ layout.bodyCenterY - layout.bodyHeight / 2 + layout.bodyHeight * profile.verticalProgress,
+ (layout.bodyDepth / 2) * profile.radiusScale * Math.cos(longitude),
+ ]
+ }
+ case 'diamond':
+ return diamond(config, longitude, latitude)
+ case 'capsule':
+ return capsule(config, longitude, latitude)
+ case 'cone': {
+ const progress = (latitude + Math.PI / 2) / Math.PI
+ const profile = morphedConeProfileAt(config, progress)
+ return [
+ (width / 2) * profile.radiusScale * Math.sin(longitude),
+ height / 2 - height * profile.verticalProgress,
+ (depth / 2) * profile.radiusScale * Math.cos(longitude),
+ ]
+ }
+ }
+}
+
+const subtract = (left: Point3, right: Point3): Point3 => [
+ left[0] - right[0],
+ left[1] - right[1],
+ left[2] - right[2],
+]
+
+const normalize = ([x, y, z]: Point3): Point3 => {
+ const length = Math.hypot(x, y, z) || 1
+ return [x / length, y / length, z / length]
+}
+
+const normalFromTangents = (
+ config: SurfaceConfig,
+ longitudeTangent: Point3,
+ latitudeTangent: Point3
+) => {
+ const orientation = config.type === 'cone' ? -1 : 1
+ return normalize([
+ orientation *
+ (longitudeTangent[1] * latitudeTangent[2] - longitudeTangent[2] * latitudeTangent[1]),
+ orientation *
+ (longitudeTangent[2] * latitudeTangent[0] - longitudeTangent[0] * latitudeTangent[2]),
+ orientation *
+ (longitudeTangent[0] * latitudeTangent[1] - longitudeTangent[1] * latitudeTangent[0]),
+ ])
+}
+
+const tangentNormalAt = (config: SurfaceConfig, longitude: number, latitude: number) => {
+ const epsilon = 0.0005
+ if (config.type === 'cone' && latitude >= Math.PI / 2 - epsilon) return [0, -1, 0] as Point3
+ const longitudeBefore = surfacePointAt(config, longitude - epsilon, latitude)
+ const longitudeAfter = surfacePointAt(config, longitude + epsilon, latitude)
+ const latitudeBefore = surfacePointAt(
+ config,
+ longitude,
+ Math.max(-Math.PI / 2, latitude - epsilon)
+ )
+ const latitudeAfter = surfacePointAt(config, longitude, Math.min(Math.PI / 2, latitude + epsilon))
+ return normalFromTangents(
+ config,
+ subtract(longitudeAfter, longitudeBefore),
+ subtract(latitudeAfter, latitudeBefore)
+ )
+}
+
+const signedMagnitude = (value: number, exponent: number) =>
+ Math.sign(value) * Math.abs(value) ** exponent
+
+const lpNormal = (config: SurfaceConfig, point: Point3, exponent: number): Point3 => {
+ const radiusX = config.width / 2 || 1
+ const radiusY = config.height / 2 || 1
+ const radiusZ = config.depth / 2 || 1
+ return normalize([
+ signedMagnitude(point[0] / radiusX, exponent - 1) / radiusX,
+ signedMagnitude(point[1] / radiusY, exponent - 1) / radiusY,
+ signedMagnitude(point[2] / radiusZ, exponent - 1) / radiusZ,
+ ])
+}
+
+const diamondNormal = (config: SurfaceConfig, point: Point3): Point3 =>
+ lpNormal(config, point, diamondExponent(config))
+
+const cubeNormal = (config: SurfaceConfig, point: Point3): Point3 => {
+ const exponent = cubeExponent(config)
+ if (Number.isFinite(exponent)) return lpNormal(config, point, exponent)
+
+ const normalized = [
+ point[0] / (config.width / 2 || 1),
+ point[1] / (config.height / 2 || 1),
+ point[2] / (config.depth / 2 || 1),
+ ] as Point3
+ const dominantAxis = normalized.reduce(
+ (largest, value, index) => (Math.abs(value) > Math.abs(normalized[largest]) ? index : largest),
+ 0
+ )
+ const normal: Point3 = [
+ dominantAxis === 0 ? Math.sign(normalized[0]) : 0,
+ dominantAxis === 1 ? Math.sign(normalized[1]) : 0,
+ dominantAxis === 2 ? Math.sign(normalized[2]) : 0,
+ ]
+ return normal
+}
+
+const lpFrontSample = (
+ config: SurfaceConfig,
+ x: number,
+ y: number,
+ exponent: number,
+ normalAt: (config: SurfaceConfig, point: Point3) => Point3
+): SurfaceSample => {
+ const radiusX = config.width / 2 || 1
+ const radiusY = config.height / 2 || 1
+ const radiusZ = config.depth / 2 || 1
+ if (!Number.isFinite(exponent)) {
+ const point: Point3 = [
+ Math.max(-radiusX, Math.min(radiusX, x)),
+ Math.max(-radiusY, Math.min(radiusY, y)),
+ radiusZ,
+ ]
+ return { point, normal: normalAt(config, point) }
+ }
+
+ const normalizedY = Math.max(-1, Math.min(1, y / radiusY))
+ const availableX = Math.max(0, 1 - Math.abs(normalizedY) ** exponent) ** (1 / exponent)
+ const surfaceX = Math.max(-radiusX * availableX, Math.min(radiusX * availableX, x))
+ const normalizedX = surfaceX / radiusX
+ const normalizedZ =
+ Math.max(0, 1 - Math.abs(normalizedX) ** exponent - Math.abs(normalizedY) ** exponent) **
+ (1 / exponent)
+ const point: Point3 = [surfaceX, normalizedY * radiusY, radiusZ * normalizedZ]
+ return { point, normal: normalAt(config, point) }
+}
+
+const ellipsoidFrontSample = (
+ x: number,
+ y: number,
+ radiusX: number,
+ radiusY: number,
+ radiusZ: number,
+ centerY = 0
+): SurfaceSample => {
+ const localY = y - centerY
+ const remaining = Math.max(0, 1 - (x / (radiusX || 1)) ** 2 - (localY / (radiusY || 1)) ** 2)
+ const z = radiusZ * Math.sqrt(remaining)
+ return {
+ point: [x, y, z],
+ normal: normalize([
+ x / (radiusX * radiusX || 1),
+ localY / (radiusY * radiusY || 1),
+ z / (radiusZ * radiusZ || 1),
+ ]),
+ }
+}
+
+const radialProfileFrontSample = (
+ config: SurfaceConfig,
+ x: number,
+ y: number,
+ profileAt: (config: SurfaceConfig, progress: number) => RadialProfile,
+ verticalDirection: -1 | 1
+): SurfaceSample => {
+ const radiusX = config.width / 2 || 1
+ const radiusZ = config.depth / 2 || 1
+ const verticalProgress = Math.max(0, Math.min(1, 0.5 + verticalDirection * (y / config.height)))
+ const radialScale = radiusScaleAtVerticalProgress(config, verticalProgress, profileAt)
+ const sectionRadiusX = radiusX * radialScale
+ const sectionRadiusZ = radiusZ * radialScale
+ const surfaceX = Math.max(-sectionRadiusX, Math.min(sectionRadiusX, x))
+ const remaining = sectionRadiusX > 0 ? Math.max(0, 1 - (surfaceX / sectionRadiusX) ** 2) : 0
+ const z = sectionRadiusZ * Math.sqrt(remaining)
+ const derivativeStep = 0.0001
+ const previousProgress = Math.max(0, verticalProgress - derivativeStep)
+ const nextProgress = Math.min(1, verticalProgress + derivativeStep)
+ const previousScale = radiusScaleAtVerticalProgress(config, previousProgress, profileAt)
+ const nextScale = radiusScaleAtVerticalProgress(config, nextProgress, profileAt)
+ const scaleDerivative = (nextScale - previousScale) / (nextProgress - previousProgress || 1)
+ const radialRemainder = Math.max(Math.sqrt(remaining), 0.0001)
+ const depthRatio = radiusZ / radiusX
+ const depthXDerivative = (-depthRatio * surfaceX) / (sectionRadiusX * radialRemainder || 1)
+ const depthYDerivative =
+ (verticalDirection * radiusZ * scaleDerivative) / (config.height * radialRemainder || 1)
+ return {
+ point: [surfaceX, y, z],
+ normal: normalize([-depthXDerivative, -depthYDerivative, 1]),
+ }
+}
+
+/** Project canonical face coordinates onto a primitive's front-facing sheet. */
+export const surfaceFrontSampleAt = (
+ config: SurfaceConfig,
+ x: number,
+ y: number
+): SurfaceSample => {
+ const radiusX = config.width / 2 || 1
+ const radiusY = config.height / 2 || 1
+ const radiusZ = config.depth / 2 || 1
+
+ switch (config.type) {
+ case 'sphere':
+ case 'mickey':
+ return ellipsoidFrontSample(x, y, radiusX, radiusY, radiusZ)
+
+ case 'cube':
+ return lpFrontSample(config, x, y, cubeExponent(config), cubeNormal)
+
+ case 'capsule': {
+ const capRadiusY = Math.min(radiusX, radiusY)
+ const straightHalf = Math.max(0, radiusY - capRadiusY)
+ const capCenterY = y < -straightHalf ? -straightHalf : y > straightHalf ? straightHalf : y
+ return ellipsoidFrontSample(x, y, radiusX, capRadiusY, radiusZ, capCenterY)
+ }
+
+ case 'cylinder':
+ return radialProfileFrontSample(config, x, y, morphedCylinderProfileAt, 1)
+
+ case 'cursor': {
+ const layout = cursorLayout(config)
+ const bodyConfig = {
+ ...config,
+ width: layout.bodyWidth,
+ height: layout.bodyHeight,
+ depth: layout.bodyDepth,
+ }
+ const sample = radialProfileFrontSample(
+ bodyConfig,
+ x,
+ y - layout.bodyCenterY,
+ cylinderProfileAt,
+ 1
+ )
+ return {
+ point: [sample.point[0], sample.point[1] + layout.bodyCenterY, sample.point[2]],
+ normal: sample.normal,
+ }
+ }
+
+ case 'cone':
+ return radialProfileFrontSample(config, x, y, morphedConeProfileAt, -1)
+
+ case 'diamond':
+ return lpFrontSample(config, x, y, diamondExponent(config), diamondNormal)
+ }
+}
+
+export const surfaceNormalAt = (
+ config: SurfaceConfig,
+ longitude: number,
+ latitude: number
+): Point3 => {
+ const point = surfacePointAt(config, longitude, latitude)
+
+ // An ellipsoid has a cheap exact normal. This is also the overwhelmingly
+ // common path for the default spherical head.
+ if (config.type === 'sphere' || config.type === 'mickey') {
+ const halfWidth = config.width / 2 || 1
+ const halfHeight = config.height / 2 || 1
+ const halfDepth = config.depth / 2 || 1
+ return normalize([
+ point[0] / (halfWidth * halfWidth),
+ point[1] / (halfHeight * halfHeight),
+ point[2] / (halfDepth * halfDepth),
+ ])
+ }
+
+ if (config.type === 'cylinder' && config.roundness <= 0 && (config.morphRoundness ?? 0) <= 0) {
+ return normalize([
+ Math.sin(longitude) / (config.width / 2 || 1),
+ 0,
+ Math.cos(longitude) / (config.depth / 2 || 1),
+ ])
+ }
+
+ if (config.type === 'diamond') {
+ return diamondNormal(config, point)
+ }
+
+ if (config.type === 'cube') {
+ return cubeNormal(config, point)
+ }
+
+ return tangentNormalAt(config, longitude, latitude)
+}
+
+export const surfaceSampleAt = (
+ config: SurfaceConfig,
+ longitude: number,
+ latitude: number
+): SurfaceSample => {
+ const point = surfacePointAt(config, longitude, latitude)
+
+ if (config.type === 'sphere' || config.type === 'mickey') {
+ const halfWidth = config.width / 2 || 1
+ const halfHeight = config.height / 2 || 1
+ const halfDepth = config.depth / 2 || 1
+ return {
+ point,
+ normal: normalize([
+ point[0] / (halfWidth * halfWidth),
+ point[1] / (halfHeight * halfHeight),
+ point[2] / (halfDepth * halfDepth),
+ ]),
+ }
+ }
+
+ if (config.type === 'cylinder' && config.roundness <= 0 && (config.morphRoundness ?? 0) <= 0) {
+ return {
+ point,
+ normal: normalize([
+ Math.sin(longitude) / (config.width / 2 || 1),
+ 0,
+ Math.cos(longitude) / (config.depth / 2 || 1),
+ ]),
+ }
+ }
+
+ if (config.type === 'diamond') {
+ return {
+ point,
+ normal: diamondNormal(config, point),
+ }
+ }
+
+ if (config.type === 'cube') {
+ return {
+ point,
+ normal: cubeNormal(config, point),
+ }
+ }
+
+ return {
+ point,
+ normal: tangentNormalAt(config, longitude, latitude),
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..e443020
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "paths": {
+ "@/*": ["./src/*"]
+ },
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "types": ["vite/client", "vitest/globals"]
+ },
+ "include": ["src", "vite.config.ts", "vitest.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..153172a
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,23 @@
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+import babel from '@rolldown/plugin-babel'
+import react, { reactCompilerPreset } from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
+import { defineConfig } from 'vite'
+
+const root = path.dirname(fileURLToPath(import.meta.url))
+
+export default defineConfig({
+ root,
+ plugins: [react(), babel({ presets: [reactCompilerPreset()] }), tailwindcss()],
+ resolve: {
+ alias: {
+ '@': path.join(root, 'src'),
+ },
+ },
+ build: {
+ outDir: path.join(root, 'dist'),
+ emptyOutDir: true,
+ },
+})
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..e505796
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ environment: 'node',
+ globals: true,
+ include: ['src/__tests__/**/*-test.ts'],
+ },
+})
diff --git a/yarn.lock b/yarn.lock
new file mode 100644
index 0000000..c90e4f3
--- /dev/null
+++ b/yarn.lock
@@ -0,0 +1,1992 @@
+# This file is generated by running "yarn install" inside your project.
+# Manual changes might be lost - proceed with caution!
+
+__metadata:
+ version: 8
+ cacheKey: 10c0
+
+"@babel/code-frame@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/code-frame@npm:7.29.7"
+ dependencies:
+ "@babel/helper-validator-identifier": "npm:^7.29.7"
+ js-tokens: "npm:^4.0.0"
+ picocolors: "npm:^1.1.1"
+ checksum: 10c0/169fc2080169a40c1760155eaaaf739bcb882df0bec76a83adbda5493645bc17270a3434b8848c494b1933e96fe1d147370001e3cda09a39f43ae30f08ef2069
+ languageName: node
+ linkType: hard
+
+"@babel/compat-data@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/compat-data@npm:7.29.7"
+ checksum: 10c0/47913f05e08a45a1c9df38c02b4b49e391005085b489432647a1abe112e5d9c75e3be8ea5972b7f6da4ec5d1339922ceb9ea02b8a25d4ed1cb8636e5261f344e
+ languageName: node
+ linkType: hard
+
+"@babel/core@npm:^7.29.0":
+ version: 7.29.7
+ resolution: "@babel/core@npm:7.29.7"
+ dependencies:
+ "@babel/code-frame": "npm:^7.29.7"
+ "@babel/generator": "npm:^7.29.7"
+ "@babel/helper-compilation-targets": "npm:^7.29.7"
+ "@babel/helper-module-transforms": "npm:^7.29.7"
+ "@babel/helpers": "npm:^7.29.7"
+ "@babel/parser": "npm:^7.29.7"
+ "@babel/template": "npm:^7.29.7"
+ "@babel/traverse": "npm:^7.29.7"
+ "@babel/types": "npm:^7.29.7"
+ "@jridgewell/remapping": "npm:^2.3.5"
+ convert-source-map: "npm:^2.0.0"
+ debug: "npm:^4.1.0"
+ gensync: "npm:^1.0.0-beta.2"
+ json5: "npm:^2.2.3"
+ semver: "npm:^6.3.1"
+ checksum: 10c0/112fb09c24de7a1de64d1de2c31fe65c4e6af4cb2fb6e6d99ea5373e6fc51e75b88581c0efae4c4c68f119a02a988c7106e95011a41530a2fb8ed793c7eaa07b
+ languageName: node
+ linkType: hard
+
+"@babel/generator@npm:^7.29.7, @babel/generator@npm:^7.29.8":
+ version: 7.29.8
+ resolution: "@babel/generator@npm:7.29.8"
+ dependencies:
+ "@babel/parser": "npm:^7.29.8"
+ "@babel/types": "npm:^7.29.8"
+ "@jridgewell/gen-mapping": "npm:^0.3.12"
+ "@jridgewell/trace-mapping": "npm:^0.3.28"
+ jsesc: "npm:^3.0.2"
+ checksum: 10c0/7b896696314a659652393b76d78276e236acd0f7fae40a9a1af7f01c76aeafc630dd0966aad6f9386d35d7c674a8e6e2d8e217c44d25fb11460e68afa9ba8441
+ languageName: node
+ linkType: hard
+
+"@babel/helper-compilation-targets@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helper-compilation-targets@npm:7.29.7"
+ dependencies:
+ "@babel/compat-data": "npm:^7.29.7"
+ "@babel/helper-validator-option": "npm:^7.29.7"
+ browserslist: "npm:^4.24.0"
+ lru-cache: "npm:^5.1.1"
+ semver: "npm:^6.3.1"
+ checksum: 10c0/4c15fd4c69a0a7047799a28a88460c19cede0a0ee8af994ea169114986f4af48b92c7393a4a3fee0456c11a656eece3448a6ed06354453d6c27cccf17195453b
+ languageName: node
+ linkType: hard
+
+"@babel/helper-globals@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helper-globals@npm:7.29.7"
+ checksum: 10c0/f38417c40b1129a1b2b519ca961b9040c8827d1444fd74068702286b91b77089431dc76b6b9d5c1496e5da2a4f3ad329c6946e688ba3fa0d1d0b3d2b4f34f36a
+ languageName: node
+ linkType: hard
+
+"@babel/helper-module-imports@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helper-module-imports@npm:7.29.7"
+ dependencies:
+ "@babel/traverse": "npm:^7.29.7"
+ "@babel/types": "npm:^7.29.7"
+ checksum: 10c0/6adf60d97356027413342a092f818d9678c4f5caff716a33e3284b5ae14e47a9e88059d421dde4ee4894691260039a12602c0e7becadc175602194b40dfa345d
+ languageName: node
+ linkType: hard
+
+"@babel/helper-module-transforms@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helper-module-transforms@npm:7.29.7"
+ dependencies:
+ "@babel/helper-module-imports": "npm:^7.29.7"
+ "@babel/helper-validator-identifier": "npm:^7.29.7"
+ "@babel/traverse": "npm:^7.29.7"
+ peerDependencies:
+ "@babel/core": ^7.0.0
+ checksum: 10c0/ee5a2172c24a42be696836f4b0d947489c9729d8adf5821885cf77d1ad5333e3c447368e9a71f67df1099570490553dccf9f888ef0a92a48aa63cb086bd8c7e1
+ languageName: node
+ linkType: hard
+
+"@babel/helper-string-parser@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helper-string-parser@npm:7.29.7"
+ checksum: 10c0/194bc0f1716e396d5ffde56ad6119745fb9557662c98611590e5e454906783a4ccb21ce93056b8eb69a4909044834e45d96e50ac695bbe9e3221648fe033c06c
+ languageName: node
+ linkType: hard
+
+"@babel/helper-validator-identifier@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helper-validator-identifier@npm:7.29.7"
+ checksum: 10c0/4795354e7ae0dcafa72de1cd04ec51252dc1498517170beaf019e03effc5b7bf13c6b21a3949a77e07b8125be7f106ed1131350d8ebd4566ae874094a726d62b
+ languageName: node
+ linkType: hard
+
+"@babel/helper-validator-option@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helper-validator-option@npm:7.29.7"
+ checksum: 10c0/d2a06c6d0ac40ba4a2f219fc2cab249c7a94bacdb2686273b7f9598571c908809b48468ff588915a346e6cc7296f60b581023d1d498b747fed06f779d335c2cc
+ languageName: node
+ linkType: hard
+
+"@babel/helpers@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helpers@npm:7.29.7"
+ dependencies:
+ "@babel/template": "npm:^7.29.7"
+ "@babel/types": "npm:^7.29.7"
+ checksum: 10c0/218e8d10953647c9f44775f5a022b227a182674853b5ea8631889deb7e1a3e4bc870388aaecf59bb8bd92a87f9a96220ed3f70a35bffec6bcf9169ecb67891ac
+ languageName: node
+ linkType: hard
+
+"@babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.7, @babel/parser@npm:^7.29.8":
+ version: 7.29.8
+ resolution: "@babel/parser@npm:7.29.8"
+ dependencies:
+ "@babel/types": "npm:^7.29.8"
+ bin:
+ parser: ./bin/babel-parser.js
+ checksum: 10c0/acc890c5e6a6dd40863a47b50bac111d7185ee6fbbe163ebe11d5214854ca2adb901462ad4d718a65090ef84bd2230e9e8ab45a2e0caccc685f1f57ab0bb1e28
+ languageName: node
+ linkType: hard
+
+"@babel/runtime@npm:^7.29.2":
+ version: 7.29.7
+ resolution: "@babel/runtime@npm:7.29.7"
+ checksum: 10c0/ca11572f7146b21e0bde6a9ed4bb6a89eafbee5f0944c7eb54d0d8a2dac962c33638a1d611e14faa71dfbb92b4b5f9236232208568a6b7d5c6f3f39ddb91771e
+ languageName: node
+ linkType: hard
+
+"@babel/template@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/template@npm:7.29.7"
+ dependencies:
+ "@babel/code-frame": "npm:^7.29.7"
+ "@babel/parser": "npm:^7.29.7"
+ "@babel/types": "npm:^7.29.7"
+ checksum: 10c0/8bb7f900dcab0e9e1c5ffbc33ca10e0d26b7b2e2ca804becb73ee771b9c4ed6e2908a4ae4a14c08560febb45d2b6b9a173955e42ad404d05f8b04840a14d9c58
+ languageName: node
+ linkType: hard
+
+"@babel/traverse@npm:^7.29.7":
+ version: 7.29.8
+ resolution: "@babel/traverse@npm:7.29.8"
+ dependencies:
+ "@babel/code-frame": "npm:^7.29.7"
+ "@babel/generator": "npm:^7.29.8"
+ "@babel/helper-globals": "npm:^7.29.7"
+ "@babel/parser": "npm:^7.29.8"
+ "@babel/template": "npm:^7.29.7"
+ "@babel/types": "npm:^7.29.8"
+ debug: "npm:^4.3.1"
+ checksum: 10c0/87a28989c434add26d787776ac6d30f749b89cb030f2a605c89f671a516a6fa165ac0476f07e2b69feed70128b2734cae1cbbf41dfe95ccf22081bd8f8b91923
+ languageName: node
+ linkType: hard
+
+"@babel/types@npm:^7.26.0, @babel/types@npm:^7.29.7, @babel/types@npm:^7.29.8":
+ version: 7.29.8
+ resolution: "@babel/types@npm:7.29.8"
+ dependencies:
+ "@babel/helper-string-parser": "npm:^7.29.7"
+ "@babel/helper-validator-identifier": "npm:^7.29.7"
+ checksum: 10c0/be7c279f0abf2a086c633e21b49c7ca80275d05283cc5a268b67a708c9914bd0c944f1422b3eb3cb37682a2af5d560abf520ccf9b01b53ecbfe6b71fbc3fdde6
+ languageName: node
+ linkType: hard
+
+"@base-ui/react@npm:^1.7.0":
+ version: 1.7.0
+ resolution: "@base-ui/react@npm:1.7.0"
+ dependencies:
+ "@babel/runtime": "npm:^7.29.2"
+ "@base-ui/utils": "npm:0.3.2"
+ "@floating-ui/react-dom": "npm:^2.1.9"
+ "@floating-ui/utils": "npm:^0.2.12"
+ use-sync-external-store: "npm:^1.6.0"
+ peerDependencies:
+ "@date-fns/tz": ^1.2.0
+ "@types/react": ^17 || ^18 || ^19
+ date-fns: ^4.0.0
+ react: ^17 || ^18 || ^19
+ react-dom: ^17 || ^18 || ^19
+ peerDependenciesMeta:
+ "@date-fns/tz":
+ optional: true
+ "@types/react":
+ optional: true
+ date-fns:
+ optional: true
+ checksum: 10c0/05bc69f68431ff30a5fac98d8e75c77af7c27749d10aeac10ab7f26843a0a8b49ca5cff32654aad1a107cb7f44aeb1705c15138c9c7f7ddc19d6321fbb263e4d
+ languageName: node
+ linkType: hard
+
+"@base-ui/utils@npm:0.3.2":
+ version: 0.3.2
+ resolution: "@base-ui/utils@npm:0.3.2"
+ dependencies:
+ "@babel/runtime": "npm:^7.29.2"
+ "@floating-ui/utils": "npm:^0.2.12"
+ reselect: "npm:^5.2.0"
+ use-sync-external-store: "npm:^1.6.0"
+ peerDependencies:
+ "@types/react": ^17 || ^18 || ^19
+ react: ^17 || ^18 || ^19
+ react-dom: ^17 || ^18 || ^19
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10c0/b523339f350679915550605e59754b5db9806a41a3b390d6f6adad29fedc0caf084c8ad1bad6bb62a463360593382defb7d3ea941a94404ed9db0142fdae5800
+ languageName: node
+ linkType: hard
+
+"@emnapi/core@npm:^1.11.1":
+ version: 1.11.3
+ resolution: "@emnapi/core@npm:1.11.3"
+ dependencies:
+ "@emnapi/wasi-threads": "npm:1.2.3"
+ tslib: "npm:^2.4.0"
+ checksum: 10c0/4ca08d349a82d5d2887ccc9e12df630877b0412ddcd59b9faee61e3c3947ccead27a18257a18bfe17abdf2b0709857808ad75d423ac49edd50c32fb140a7ed6e
+ languageName: node
+ linkType: hard
+
+"@emnapi/runtime@npm:^1.11.1":
+ version: 1.11.3
+ resolution: "@emnapi/runtime@npm:1.11.3"
+ dependencies:
+ tslib: "npm:^2.4.0"
+ checksum: 10c0/a00f1020fefb9d4145c367f93a9fddb383a00da8ffd7871e20b19659890379b83aeecb9f84d7d0eda5456343f4a09eb05b0acb5153b0d3d889539dedb1ed87c3
+ languageName: node
+ linkType: hard
+
+"@emnapi/wasi-threads@npm:1.2.3, @emnapi/wasi-threads@npm:^1.2.2":
+ version: 1.2.3
+ resolution: "@emnapi/wasi-threads@npm:1.2.3"
+ dependencies:
+ tslib: "npm:^2.4.0"
+ checksum: 10c0/5aed84bc5dbe867973b20406ca1ed95661a4892ecaef80378fdf2d37424b3f7b9c428df8a3e1d82b783a3345a530032bf049e699e1f113ea52203c3e9b4e6ce5
+ languageName: node
+ linkType: hard
+
+"@floating-ui/core@npm:^1.8.0":
+ version: 1.8.0
+ resolution: "@floating-ui/core@npm:1.8.0"
+ dependencies:
+ "@floating-ui/utils": "npm:^0.2.12"
+ checksum: 10c0/cece958f6fab0f8cd7740cd57988c4f8bc5356dca39dd4c093433d44c91952c3a02c7a54d93d7ed6c078b6544d531a29f66621d2839c8bfbb4467dca6aed7df0
+ languageName: node
+ linkType: hard
+
+"@floating-ui/dom@npm:^1.8.0":
+ version: 1.8.0
+ resolution: "@floating-ui/dom@npm:1.8.0"
+ dependencies:
+ "@floating-ui/core": "npm:^1.8.0"
+ "@floating-ui/utils": "npm:^0.2.12"
+ checksum: 10c0/c32b2de7a596b69cfffa909c7028825e14f6319be1b45da529f8943c89fe16b75a49c214e800c87ded3f172353d3b99163d50072f11410488e756961b7b629cc
+ languageName: node
+ linkType: hard
+
+"@floating-ui/react-dom@npm:^2.1.9":
+ version: 2.1.9
+ resolution: "@floating-ui/react-dom@npm:2.1.9"
+ dependencies:
+ "@floating-ui/dom": "npm:^1.8.0"
+ peerDependencies:
+ react: ">=16.8.0"
+ react-dom: ">=16.8.0"
+ checksum: 10c0/9fa9851069738cb9591a1ab2da16e257f193eae5fd2d6d48ad0b1e73f3c049929b6805c5ad43d4af3c1da63c60b215b0ee4651a1fe5caeb80e66d9d423744348
+ languageName: node
+ linkType: hard
+
+"@floating-ui/utils@npm:^0.2.12":
+ version: 0.2.12
+ resolution: "@floating-ui/utils@npm:0.2.12"
+ checksum: 10c0/bb910b90d56991a62011afaf5f8e0c4b7fb6dab69403f4dd17fbd6eb25560b28d533dff9791f270b4f459852e9006a1077b5d73cbedb5e34d9424afc6a828314
+ languageName: node
+ linkType: hard
+
+"@isaacs/fs-minipass@npm:^4.0.0":
+ version: 4.0.1
+ resolution: "@isaacs/fs-minipass@npm:4.0.1"
+ dependencies:
+ minipass: "npm:^7.0.4"
+ checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2
+ languageName: node
+ linkType: hard
+
+"@jridgewell/gen-mapping@npm:^0.3.12, @jridgewell/gen-mapping@npm:^0.3.5":
+ version: 0.3.13
+ resolution: "@jridgewell/gen-mapping@npm:0.3.13"
+ dependencies:
+ "@jridgewell/sourcemap-codec": "npm:^1.5.0"
+ "@jridgewell/trace-mapping": "npm:^0.3.24"
+ checksum: 10c0/9a7d65fb13bd9aec1fbab74cda08496839b7e2ceb31f5ab922b323e94d7c481ce0fc4fd7e12e2610915ed8af51178bdc61e168e92a8c8b8303b030b03489b13b
+ languageName: node
+ linkType: hard
+
+"@jridgewell/remapping@npm:^2.3.5":
+ version: 2.3.5
+ resolution: "@jridgewell/remapping@npm:2.3.5"
+ dependencies:
+ "@jridgewell/gen-mapping": "npm:^0.3.5"
+ "@jridgewell/trace-mapping": "npm:^0.3.24"
+ checksum: 10c0/3de494219ffeb2c5c38711d0d7bb128097edf91893090a2dbc8ee0b55d092bb7347b1fd0f478486c5eab010e855c73927b1666f2107516d472d24a73017d1194
+ languageName: node
+ linkType: hard
+
+"@jridgewell/resolve-uri@npm:^3.1.0":
+ version: 3.1.2
+ resolution: "@jridgewell/resolve-uri@npm:3.1.2"
+ checksum: 10c0/d502e6fb516b35032331406d4e962c21fe77cdf1cbdb49c6142bcbd9e30507094b18972778a6e27cbad756209cfe34b1a27729e6fa08a2eb92b33943f680cf1e
+ languageName: node
+ linkType: hard
+
+"@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0, @jridgewell/sourcemap-codec@npm:^1.5.5":
+ version: 1.5.5
+ resolution: "@jridgewell/sourcemap-codec@npm:1.5.5"
+ checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0
+ languageName: node
+ linkType: hard
+
+"@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.28":
+ version: 0.3.31
+ resolution: "@jridgewell/trace-mapping@npm:0.3.31"
+ dependencies:
+ "@jridgewell/resolve-uri": "npm:^3.1.0"
+ "@jridgewell/sourcemap-codec": "npm:^1.4.14"
+ checksum: 10c0/4b30ec8cd56c5fd9a661f088230af01e0c1a3888d11ffb6b47639700f71225be21d1f7e168048d6d4f9449207b978a235c07c8f15c07705685d16dc06280e9d9
+ languageName: node
+ linkType: hard
+
+"@napi-rs/wasm-runtime@npm:^1.1.4":
+ version: 1.2.3
+ resolution: "@napi-rs/wasm-runtime@npm:1.2.3"
+ dependencies:
+ "@tybys/wasm-util": "npm:^0.10.3"
+ peerDependencies:
+ "@emnapi/core": ^1.7.1 || ^2.0.0-alpha.4
+ "@emnapi/runtime": ^1.7.1 || ^2.0.0-alpha.4
+ checksum: 10c0/6da0a4bf9df79e9abfb3e3d462f6a5d42889be39426040038c9dd5925865585d7dbd06dd439c2ffc20f49ef2751412da5bd748cfb3c5b049142d72c0550f6f79
+ languageName: node
+ linkType: hard
+
+"@oxc-project/types@npm:=0.144.0":
+ version: 0.144.0
+ resolution: "@oxc-project/types@npm:0.144.0"
+ checksum: 10c0/997c6c33f09706af604ece0e99c698965757887aca4b42c5fb5ddcb11c39876c1e824b188b1e6582276355468a9b13f955d3b822d3b532cd6bedbeb64b603ff0
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-android-arm64@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-android-arm64@npm:1.2.4"
+ conditions: os=android & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-darwin-arm64@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-darwin-arm64@npm:1.2.4"
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-darwin-x64@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-darwin-x64@npm:1.2.4"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-freebsd-x64@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-freebsd-x64@npm:1.2.4"
+ conditions: os=freebsd & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-linux-arm-gnueabihf@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.2.4"
+ conditions: os=linux & cpu=arm
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-linux-arm64-gnu@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.2.4"
+ conditions: os=linux & cpu=arm64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-linux-arm64-musl@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-linux-arm64-musl@npm:1.2.4"
+ conditions: os=linux & cpu=arm64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-linux-ppc64-gnu@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.2.4"
+ conditions: os=linux & cpu=ppc64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-linux-s390x-gnu@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.2.4"
+ conditions: os=linux & cpu=s390x & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-linux-x64-gnu@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-linux-x64-gnu@npm:1.2.4"
+ conditions: os=linux & cpu=x64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-linux-x64-musl@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-linux-x64-musl@npm:1.2.4"
+ conditions: os=linux & cpu=x64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-openharmony-arm64@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-openharmony-arm64@npm:1.2.4"
+ conditions: os=openharmony & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-win32-arm64-msvc@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.2.4"
+ conditions: os=win32 & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-win32-x64-msvc@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-win32-x64-msvc@npm:1.2.4"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@rolldown/plugin-babel@npm:^0.2.3":
+ version: 0.2.3
+ resolution: "@rolldown/plugin-babel@npm:0.2.3"
+ dependencies:
+ picomatch: "npm:^4.0.4"
+ peerDependencies:
+ "@babel/core": ^7.29.0 || ^8.0.0-rc.1
+ "@babel/plugin-transform-runtime": ^7.29.0 || ^8.0.0-rc.1
+ "@babel/runtime": ^7.27.0 || ^8.0.0-rc.1
+ rolldown: ^1.0.0-rc.5
+ vite: ^8.0.0
+ peerDependenciesMeta:
+ "@babel/plugin-transform-runtime":
+ optional: true
+ "@babel/runtime":
+ optional: true
+ vite:
+ optional: true
+ checksum: 10c0/84afe9854137b576f963f4fd11080dc0b8afe6243d565513d33ddfed9ea9149652110def881a3911b5e889f5fa505df036c6839f4d0fd01d4222297d7f2baa21
+ languageName: node
+ linkType: hard
+
+"@rolldown/pluginutils@npm:^1.0.0, @rolldown/pluginutils@npm:^1.0.1":
+ version: 1.0.1
+ resolution: "@rolldown/pluginutils@npm:1.0.1"
+ checksum: 10c0/99d9b06d90196823e4d8c841f258db7a16e5dbba5824a2962b05d907b79f1ba929d56f22dd744fd530936e568c865ee56a719dc31e57e13bc0a8eb4764a8d8dd
+ languageName: node
+ linkType: hard
+
+"@standard-schema/spec@npm:^1.1.0":
+ version: 1.1.0
+ resolution: "@standard-schema/spec@npm:1.1.0"
+ checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526
+ languageName: node
+ linkType: hard
+
+"@tailwindcss/node@npm:4.3.3":
+ version: 4.3.3
+ resolution: "@tailwindcss/node@npm:4.3.3"
+ dependencies:
+ "@jridgewell/remapping": "npm:^2.3.5"
+ enhanced-resolve: "npm:^5.24.1"
+ jiti: "npm:^2.7.0"
+ lightningcss: "npm:1.32.0"
+ magic-string: "npm:^0.30.21"
+ source-map-js: "npm:^1.2.1"
+ tailwindcss: "npm:4.3.3"
+ checksum: 10c0/0123669be5b3e78d42b0e10cb34d547fb8574c8e7dc7a97a141c8a4ab4215852b5b1b99b97763e898e1da1493ee8424cb72e28225cc4876828c95156f94d7f0e
+ languageName: node
+ linkType: hard
+
+"@tailwindcss/oxide-android-arm64@npm:4.3.3":
+ version: 4.3.3
+ resolution: "@tailwindcss/oxide-android-arm64@npm:4.3.3"
+ conditions: os=android & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@tailwindcss/oxide-darwin-arm64@npm:4.3.3":
+ version: 4.3.3
+ resolution: "@tailwindcss/oxide-darwin-arm64@npm:4.3.3"
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@tailwindcss/oxide-darwin-x64@npm:4.3.3":
+ version: 4.3.3
+ resolution: "@tailwindcss/oxide-darwin-x64@npm:4.3.3"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@tailwindcss/oxide-freebsd-x64@npm:4.3.3":
+ version: 4.3.3
+ resolution: "@tailwindcss/oxide-freebsd-x64@npm:4.3.3"
+ conditions: os=freebsd & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.3.3":
+ version: 4.3.3
+ resolution: "@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.3.3"
+ conditions: os=linux & cpu=arm
+ languageName: node
+ linkType: hard
+
+"@tailwindcss/oxide-linux-arm64-gnu@npm:4.3.3":
+ version: 4.3.3
+ resolution: "@tailwindcss/oxide-linux-arm64-gnu@npm:4.3.3"
+ conditions: os=linux & cpu=arm64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@tailwindcss/oxide-linux-arm64-musl@npm:4.3.3":
+ version: 4.3.3
+ resolution: "@tailwindcss/oxide-linux-arm64-musl@npm:4.3.3"
+ conditions: os=linux & cpu=arm64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"@tailwindcss/oxide-linux-x64-gnu@npm:4.3.3":
+ version: 4.3.3
+ resolution: "@tailwindcss/oxide-linux-x64-gnu@npm:4.3.3"
+ conditions: os=linux & cpu=x64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@tailwindcss/oxide-linux-x64-musl@npm:4.3.3":
+ version: 4.3.3
+ resolution: "@tailwindcss/oxide-linux-x64-musl@npm:4.3.3"
+ conditions: os=linux & cpu=x64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"@tailwindcss/oxide-wasm32-wasi@npm:4.3.3":
+ version: 4.3.3
+ resolution: "@tailwindcss/oxide-wasm32-wasi@npm:4.3.3"
+ dependencies:
+ "@emnapi/core": "npm:^1.11.1"
+ "@emnapi/runtime": "npm:^1.11.1"
+ "@emnapi/wasi-threads": "npm:^1.2.2"
+ "@napi-rs/wasm-runtime": "npm:^1.1.4"
+ "@tybys/wasm-util": "npm:^0.10.2"
+ tslib: "npm:^2.8.1"
+ conditions: cpu=wasm32
+ languageName: node
+ linkType: hard
+
+"@tailwindcss/oxide-win32-arm64-msvc@npm:4.3.3":
+ version: 4.3.3
+ resolution: "@tailwindcss/oxide-win32-arm64-msvc@npm:4.3.3"
+ conditions: os=win32 & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@tailwindcss/oxide-win32-x64-msvc@npm:4.3.3":
+ version: 4.3.3
+ resolution: "@tailwindcss/oxide-win32-x64-msvc@npm:4.3.3"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@tailwindcss/oxide@npm:4.3.3":
+ version: 4.3.3
+ resolution: "@tailwindcss/oxide@npm:4.3.3"
+ dependencies:
+ "@tailwindcss/oxide-android-arm64": "npm:4.3.3"
+ "@tailwindcss/oxide-darwin-arm64": "npm:4.3.3"
+ "@tailwindcss/oxide-darwin-x64": "npm:4.3.3"
+ "@tailwindcss/oxide-freebsd-x64": "npm:4.3.3"
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "npm:4.3.3"
+ "@tailwindcss/oxide-linux-arm64-gnu": "npm:4.3.3"
+ "@tailwindcss/oxide-linux-arm64-musl": "npm:4.3.3"
+ "@tailwindcss/oxide-linux-x64-gnu": "npm:4.3.3"
+ "@tailwindcss/oxide-linux-x64-musl": "npm:4.3.3"
+ "@tailwindcss/oxide-wasm32-wasi": "npm:4.3.3"
+ "@tailwindcss/oxide-win32-arm64-msvc": "npm:4.3.3"
+ "@tailwindcss/oxide-win32-x64-msvc": "npm:4.3.3"
+ dependenciesMeta:
+ "@tailwindcss/oxide-android-arm64":
+ optional: true
+ "@tailwindcss/oxide-darwin-arm64":
+ optional: true
+ "@tailwindcss/oxide-darwin-x64":
+ optional: true
+ "@tailwindcss/oxide-freebsd-x64":
+ optional: true
+ "@tailwindcss/oxide-linux-arm-gnueabihf":
+ optional: true
+ "@tailwindcss/oxide-linux-arm64-gnu":
+ optional: true
+ "@tailwindcss/oxide-linux-arm64-musl":
+ optional: true
+ "@tailwindcss/oxide-linux-x64-gnu":
+ optional: true
+ "@tailwindcss/oxide-linux-x64-musl":
+ optional: true
+ "@tailwindcss/oxide-wasm32-wasi":
+ optional: true
+ "@tailwindcss/oxide-win32-arm64-msvc":
+ optional: true
+ "@tailwindcss/oxide-win32-x64-msvc":
+ optional: true
+ checksum: 10c0/6c38b25226dad252347451ed8953a998432b51f1e068b25e021215b886ccf8bcf3af58faf5f4ab50368091e657ca2fa341df1deca6101651eddb567122f2acb5
+ languageName: node
+ linkType: hard
+
+"@tailwindcss/vite@npm:^4.3.3":
+ version: 4.3.3
+ resolution: "@tailwindcss/vite@npm:4.3.3"
+ dependencies:
+ "@tailwindcss/node": "npm:4.3.3"
+ "@tailwindcss/oxide": "npm:4.3.3"
+ tailwindcss: "npm:4.3.3"
+ peerDependencies:
+ vite: ^5.2.0 || ^6 || ^7 || ^8
+ checksum: 10c0/bb1fe12f7a0bb8f6d6142f6bb2e96060ee853902314ef838e8d7f50cf30cc4b07adb7c2c86be2164e0d62eb581b8cc4df2d9aa13b4449f597a67f73c5772d549
+ languageName: node
+ linkType: hard
+
+"@tybys/wasm-util@npm:^0.10.2, @tybys/wasm-util@npm:^0.10.3":
+ version: 0.10.3
+ resolution: "@tybys/wasm-util@npm:0.10.3"
+ dependencies:
+ tslib: "npm:^2.4.0"
+ checksum: 10c0/fd2bd2a79c6cd8c79ed1cf7a0fa375c64589264c88a27acaf9756d556b453ea222b62a4f68dd2fbb8b3a78b6bab3b1f4fb2431b6afc6aeda8344b53a521a1cd3
+ languageName: node
+ linkType: hard
+
+"@types/chai@npm:^5.2.2":
+ version: 5.2.3
+ resolution: "@types/chai@npm:5.2.3"
+ dependencies:
+ "@types/deep-eql": "npm:*"
+ assertion-error: "npm:^2.0.1"
+ checksum: 10c0/e0ef1de3b6f8045a5e473e867c8565788c444271409d155588504840ad1a53611011f85072188c2833941189400228c1745d78323dac13fcede9c2b28bacfb2f
+ languageName: node
+ linkType: hard
+
+"@types/deep-eql@npm:*":
+ version: 4.0.2
+ resolution: "@types/deep-eql@npm:4.0.2"
+ checksum: 10c0/bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844
+ languageName: node
+ linkType: hard
+
+"@types/estree@npm:^1.0.0":
+ version: 1.0.9
+ resolution: "@types/estree@npm:1.0.9"
+ checksum: 10c0/3ad3286ca2988cd550dafb8f2ad599c8474868e954fa601a36655bdfefd8039f7c714b8c1c7f2ae219ffbd58bd4660e66fa7479a0120fc02d4777057d4865387
+ languageName: node
+ linkType: hard
+
+"@types/node@npm:^24.10.1":
+ version: 24.13.3
+ resolution: "@types/node@npm:24.13.3"
+ dependencies:
+ undici-types: "npm:~7.18.0"
+ checksum: 10c0/a5bc08f49b9581dcdca90e02cd77197a3c799840807664942e5cd5161a553d4d2ae8064f7e3294410d748d7d098b5c1848be7fa50c06f29c4193ab16be4e59eb
+ languageName: node
+ linkType: hard
+
+"@types/react-dom@npm:^19.2.3":
+ version: 19.2.4
+ resolution: "@types/react-dom@npm:19.2.4"
+ peerDependencies:
+ "@types/react": ^19.2.0
+ checksum: 10c0/b7d854ce17bb51a3a067168268a90f0123d10dc490f63f1ff5409f07ac715febeb8f5b7b473404a9d437106571e0312fc2937a945634d590abfc9aa46a14b01b
+ languageName: node
+ linkType: hard
+
+"@types/react@npm:^19.2.14":
+ version: 19.2.18
+ resolution: "@types/react@npm:19.2.18"
+ dependencies:
+ csstype: "npm:^3.2.2"
+ checksum: 10c0/d04216172b4b4362b310017c210dfbe019fb4f4e7dffd0313e70b3acb3051d5c3e76e7e84e3cf4c6a3c824993ffadfe3949e589a6398a09d4200e7670e2962de
+ languageName: node
+ linkType: hard
+
+"@vitejs/plugin-react@npm:^6.0.2":
+ version: 6.0.5
+ resolution: "@vitejs/plugin-react@npm:6.0.5"
+ dependencies:
+ "@rolldown/pluginutils": "npm:^1.0.1"
+ peerDependencies:
+ "@rolldown/plugin-babel": ^0.1.7 || ^0.2.0
+ babel-plugin-react-compiler: ^1.0.0
+ vite: ^8.0.0
+ peerDependenciesMeta:
+ "@rolldown/plugin-babel":
+ optional: true
+ babel-plugin-react-compiler:
+ optional: true
+ checksum: 10c0/fb02246fe3652d7fb746190bdd098b9e29918dfcf8c67b9c0c37300ce789e82331b2ba761530ac6ac9a61390b774d44f401787bd1c87a6c866d1e74a745cc1a0
+ languageName: node
+ linkType: hard
+
+"@vitest/expect@npm:4.1.10":
+ version: 4.1.10
+ resolution: "@vitest/expect@npm:4.1.10"
+ dependencies:
+ "@standard-schema/spec": "npm:^1.1.0"
+ "@types/chai": "npm:^5.2.2"
+ "@vitest/spy": "npm:4.1.10"
+ "@vitest/utils": "npm:4.1.10"
+ chai: "npm:^6.2.2"
+ tinyrainbow: "npm:^3.1.0"
+ checksum: 10c0/a817ad0d9bd6a039776a7228d54fb8319c17e4af15917407f5566ac61781a8511f591d302519d6999217399915bc3c0290028189fc73f5c38f80cb01b6f19c8d
+ languageName: node
+ linkType: hard
+
+"@vitest/mocker@npm:4.1.10":
+ version: 4.1.10
+ resolution: "@vitest/mocker@npm:4.1.10"
+ dependencies:
+ "@vitest/spy": "npm:4.1.10"
+ estree-walker: "npm:^3.0.3"
+ magic-string: "npm:^0.30.21"
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+ checksum: 10c0/4aa70b0df58681652e2e28093437fb2e8f4d02a6d03f5619abc266ac1c5ae5f43326148061d13ae6e071e0f6cfcf7634659af63644de8ce098a7c98949a3d1ad
+ languageName: node
+ linkType: hard
+
+"@vitest/pretty-format@npm:4.1.10":
+ version: 4.1.10
+ resolution: "@vitest/pretty-format@npm:4.1.10"
+ dependencies:
+ tinyrainbow: "npm:^3.1.0"
+ checksum: 10c0/1a5daba730ffe23f2000bff484b4b2842f3b178d93663cb487b215516b8d3b62caa3e2bb2a3c63307b61a9fe58fb9bfff38559bc0c5e49d8aa403d6803a1d918
+ languageName: node
+ linkType: hard
+
+"@vitest/runner@npm:4.1.10":
+ version: 4.1.10
+ resolution: "@vitest/runner@npm:4.1.10"
+ dependencies:
+ "@vitest/utils": "npm:4.1.10"
+ pathe: "npm:^2.0.3"
+ checksum: 10c0/554b72639de9694271b99be8ae273fe12ec793093ec91cce143816cd1187d40b7138a4d9d4de4f456cfca9567de986825bff97e107c05b9eb4abc130e854286d
+ languageName: node
+ linkType: hard
+
+"@vitest/snapshot@npm:4.1.10":
+ version: 4.1.10
+ resolution: "@vitest/snapshot@npm:4.1.10"
+ dependencies:
+ "@vitest/pretty-format": "npm:4.1.10"
+ "@vitest/utils": "npm:4.1.10"
+ magic-string: "npm:^0.30.21"
+ pathe: "npm:^2.0.3"
+ checksum: 10c0/e71398725f51af5fd0c07bb4b957d0f987daf9b4c564ac24cb2a4d1afde1a6939f535ac17761a32dcc41b0a1e6d4088af66dc44df89fdebebb92aabed1a92b5f
+ languageName: node
+ linkType: hard
+
+"@vitest/spy@npm:4.1.10":
+ version: 4.1.10
+ resolution: "@vitest/spy@npm:4.1.10"
+ checksum: 10c0/e5c08012560af6727fd66741c5cda25560d7c5442103d0c83e4276a9b0dd90b9da6cdf823a461195229a16c6ff87768ce788a68d0fa29dea73ee285618668178
+ languageName: node
+ linkType: hard
+
+"@vitest/utils@npm:4.1.10":
+ version: 4.1.10
+ resolution: "@vitest/utils@npm:4.1.10"
+ dependencies:
+ "@vitest/pretty-format": "npm:4.1.10"
+ convert-source-map: "npm:^2.0.0"
+ tinyrainbow: "npm:^3.1.0"
+ checksum: 10c0/05b0ecec6997ec22fc08377e57dbd8fa37992e05961f3a7a916d98b1ab56d15c2a87dbd83d392b628242bdc156b1705e7fa60a3bf0c54bdb51158c153e05fc5d
+ languageName: node
+ linkType: hard
+
+"abbrev@npm:^5.0.0":
+ version: 5.0.0
+ resolution: "abbrev@npm:5.0.0"
+ checksum: 10c0/8e88f5c798ea4562d28c5a3e9ad69e3879890bc5d695d8f2dffb8609be4c890aacc8f80ef4553fdd2c6a62d70c2ce8bc57b38074e383beb7487bdafa9ed42ea5
+ languageName: node
+ linkType: hard
+
+"assertion-error@npm:^2.0.1":
+ version: 2.0.1
+ resolution: "assertion-error@npm:2.0.1"
+ checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8
+ languageName: node
+ linkType: hard
+
+"babel-plugin-react-compiler@npm:^1.0.0":
+ version: 1.0.0
+ resolution: "babel-plugin-react-compiler@npm:1.0.0"
+ dependencies:
+ "@babel/types": "npm:^7.26.0"
+ checksum: 10c0/9406267ada8d7dbdfe8906b40ecadb816a5f4cee2922bee23f7729293b369624ee135b5a9b0f263851c263c9787522ac5d97016c9a2b82d1668300e42b18aff8
+ languageName: node
+ linkType: hard
+
+"baseline-browser-mapping@npm:^2.11.12":
+ version: 2.11.14
+ resolution: "baseline-browser-mapping@npm:2.11.14"
+ bin:
+ baseline-browser-mapping: dist/cli.cjs
+ checksum: 10c0/b0d248660ed8a09b0738fb9ca383668e8e445ad23b3efa8a5c41ee91fdf09399766513f97e5effd77e2e5027c1eec225f84bbfdf01586871e5ba2bb4cce8ea83
+ languageName: node
+ linkType: hard
+
+"bible-strong-avatar-lab@workspace:.":
+ version: 0.0.0-use.local
+ resolution: "bible-strong-avatar-lab@workspace:."
+ dependencies:
+ "@babel/core": "npm:^7.29.0"
+ "@babel/parser": "npm:^7.28.6"
+ "@base-ui/react": "npm:^1.7.0"
+ "@rolldown/plugin-babel": "npm:^0.2.3"
+ "@tailwindcss/vite": "npm:^4.3.3"
+ "@types/node": "npm:^24.10.1"
+ "@types/react": "npm:^19.2.14"
+ "@types/react-dom": "npm:^19.2.3"
+ "@vitejs/plugin-react": "npm:^6.0.2"
+ babel-plugin-react-compiler: "npm:^1.0.0"
+ class-variance-authority: "npm:^0.7.1"
+ clsx: "npm:^2.1.1"
+ lucide-react: "npm:^1.31.0"
+ motion: "npm:^13.0.0"
+ prettier: "npm:^3.8.1"
+ react: "npm:19.2.3"
+ react-dom: "npm:19.2.3"
+ rolldown: "npm:^1.0.0-rc.5"
+ tailwind-merge: "npm:^3.6.0"
+ tailwindcss: "npm:^4.3.3"
+ typescript: "npm:~6.0.3"
+ vite: "npm:^8.0.13"
+ vitest: "npm:^4.1.10"
+ languageName: unknown
+ linkType: soft
+
+"browserslist@npm:^4.24.0":
+ version: 4.28.8
+ resolution: "browserslist@npm:4.28.8"
+ dependencies:
+ baseline-browser-mapping: "npm:^2.11.12"
+ caniuse-lite: "npm:^1.0.30001809"
+ electron-to-chromium: "npm:^1.5.402"
+ node-releases: "npm:^2.0.53"
+ update-browserslist-db: "npm:^1.3.0"
+ bin:
+ browserslist: cli.js
+ checksum: 10c0/047dd517c8d1f1f56a253d752c3127b8ad01c58e4cbb7e21cb5cd07ebd13e083a2237c3afb13eb60674282c9132bd4534aea2c3ff7c6f7d29be0687a00cbd537
+ languageName: node
+ linkType: hard
+
+"caniuse-lite@npm:^1.0.30001809":
+ version: 1.0.30001809
+ resolution: "caniuse-lite@npm:1.0.30001809"
+ checksum: 10c0/cac2ed4e66cc6c4cbf126b94d2c02012566eb92f93c89949290f99edcd2bdc4ed1290b5c537ccb9b42c773936a479ed468e5d4e9b8938b126a0947090e42e008
+ languageName: node
+ linkType: hard
+
+"chai@npm:^6.2.2":
+ version: 6.2.2
+ resolution: "chai@npm:6.2.2"
+ checksum: 10c0/e6c69e5f0c11dffe6ea13d0290936ebb68fcc1ad688b8e952e131df6a6d5797d5e860bc55cef1aca2e950c3e1f96daf79e9d5a70fb7dbaab4e46355e2635ed53
+ languageName: node
+ linkType: hard
+
+"chownr@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "chownr@npm:3.0.0"
+ checksum: 10c0/43925b87700f7e3893296c8e9c56cc58f926411cce3a6e5898136daaf08f08b9a8eb76d37d3267e707d0dcc17aed2e2ebdf5848c0c3ce95cf910a919935c1b10
+ languageName: node
+ linkType: hard
+
+"class-variance-authority@npm:^0.7.1":
+ version: 0.7.1
+ resolution: "class-variance-authority@npm:0.7.1"
+ dependencies:
+ clsx: "npm:^2.1.1"
+ checksum: 10c0/0f438cea22131808b99272de0fa933c2532d5659773bfec0c583de7b3f038378996d3350683426b8e9c74a6286699382106d71fbec52f0dd5fbb191792cccb5b
+ languageName: node
+ linkType: hard
+
+"clsx@npm:^2.1.1":
+ version: 2.1.1
+ resolution: "clsx@npm:2.1.1"
+ checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839
+ languageName: node
+ linkType: hard
+
+"convert-source-map@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "convert-source-map@npm:2.0.0"
+ checksum: 10c0/8f2f7a27a1a011cc6cc88cc4da2d7d0cfa5ee0369508baae3d98c260bb3ac520691464e5bbe4ae7cdf09860c1d69ecc6f70c63c6e7c7f7e3f18ec08484dc7d9b
+ languageName: node
+ linkType: hard
+
+"csstype@npm:^3.2.2":
+ version: 3.2.3
+ resolution: "csstype@npm:3.2.3"
+ checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce
+ languageName: node
+ linkType: hard
+
+"debug@npm:^4.1.0, debug@npm:^4.3.1":
+ version: 4.4.3
+ resolution: "debug@npm:4.4.3"
+ dependencies:
+ ms: "npm:^2.1.3"
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+ checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6
+ languageName: node
+ linkType: hard
+
+"detect-libc@npm:^2.0.3":
+ version: 2.1.2
+ resolution: "detect-libc@npm:2.1.2"
+ checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4
+ languageName: node
+ linkType: hard
+
+"electron-to-chromium@npm:^1.5.402":
+ version: 1.5.405
+ resolution: "electron-to-chromium@npm:1.5.405"
+ checksum: 10c0/6e85abde0e3c77945c3cdd6f28741cba268474a62bfbe8c59c5728438a03e689d1df74fd4526a73064f452350ef7fd0b8edb721fa979a791813b81ec2b1a0d8c
+ languageName: node
+ linkType: hard
+
+"enhanced-resolve@npm:^5.24.1":
+ version: 5.24.5
+ resolution: "enhanced-resolve@npm:5.24.5"
+ dependencies:
+ graceful-fs: "npm:^4.2.4"
+ tapable: "npm:^2.3.3"
+ checksum: 10c0/76c32ce5485ecea0ef808c9289bc6f7c49ce85142943598d89a23a846f2909556d3dc6db97a007442a48c185baad5514c1c76d20a2656497f9511d13ccfab73b
+ languageName: node
+ linkType: hard
+
+"env-paths@npm:^2.2.0":
+ version: 2.2.1
+ resolution: "env-paths@npm:2.2.1"
+ checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4
+ languageName: node
+ linkType: hard
+
+"es-module-lexer@npm:^2.0.0":
+ version: 2.3.1
+ resolution: "es-module-lexer@npm:2.3.1"
+ checksum: 10c0/ada8b222772b5b8ea92eb6054c383233207418621855a07b480fdd36979b658a41414be09e793fcdd8a67a182741475f47830a01ff2ebd4353d7f6965c7c45f9
+ languageName: node
+ linkType: hard
+
+"escalade@npm:^3.2.0":
+ version: 3.2.0
+ resolution: "escalade@npm:3.2.0"
+ checksum: 10c0/ced4dd3a78e15897ed3be74e635110bbf3b08877b0a41be50dcb325ee0e0b5f65fc2d50e9845194d7c4633f327e2e1c6cce00a71b617c5673df0374201d67f65
+ languageName: node
+ linkType: hard
+
+"estree-walker@npm:^3.0.3":
+ version: 3.0.3
+ resolution: "estree-walker@npm:3.0.3"
+ dependencies:
+ "@types/estree": "npm:^1.0.0"
+ checksum: 10c0/c12e3c2b2642d2bcae7d5aa495c60fa2f299160946535763969a1c83fc74518ffa9c2cd3a8b69ac56aea547df6a8aac25f729a342992ef0bbac5f1c73e78995d
+ languageName: node
+ linkType: hard
+
+"expect-type@npm:^1.3.0":
+ version: 1.4.0
+ resolution: "expect-type@npm:1.4.0"
+ checksum: 10c0/d40d76b8570695d36587beb3cc28494da2ca3ec8f04e67f5622ed2d372d850e401a9adef19c6835e1a8173903f157c79540b34c7b3fbd7cd8ce726cc903c57b7
+ languageName: node
+ linkType: hard
+
+"exponential-backoff@npm:^3.1.1":
+ version: 3.1.3
+ resolution: "exponential-backoff@npm:3.1.3"
+ checksum: 10c0/77e3ae682b7b1f4972f563c6dbcd2b0d54ac679e62d5d32f3e5085feba20483cf28bd505543f520e287a56d4d55a28d7874299941faf637e779a1aa5994d1267
+ languageName: node
+ linkType: hard
+
+"fdir@npm:^6.5.0":
+ version: 6.5.0
+ resolution: "fdir@npm:6.5.0"
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+ checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f
+ languageName: node
+ linkType: hard
+
+"framer-motion@npm:^13.1.0":
+ version: 13.1.0
+ resolution: "framer-motion@npm:13.1.0"
+ dependencies:
+ motion-dom: "npm:^13.0.0"
+ motion-utils: "npm:^13.0.0"
+ tslib: "npm:^2.4.0"
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ react:
+ optional: true
+ react-dom:
+ optional: true
+ checksum: 10c0/0fa4c5d17369f288bfff434191fbd112d44ae06f7a99370e691128db1e302804332e5ec5e980c7a59ffcdb3248fe68f96bce5931d0c066eff55ec6d493fc197c
+ languageName: node
+ linkType: hard
+
+"fsevents@npm:~2.3.3":
+ version: 2.3.3
+ resolution: "fsevents@npm:2.3.3"
+ dependencies:
+ node-gyp: "npm:latest"
+ checksum: 10c0/a1f0c44595123ed717febbc478aa952e47adfc28e2092be66b8ab1635147254ca6cfe1df792a8997f22716d4cbafc73309899ff7bfac2ac3ad8cf2e4ecc3ec60
+ conditions: os=darwin
+ languageName: node
+ linkType: hard
+
+"fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin":
+ version: 2.3.3
+ resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1"
+ dependencies:
+ node-gyp: "npm:latest"
+ conditions: os=darwin
+ languageName: node
+ linkType: hard
+
+"gensync@npm:^1.0.0-beta.2":
+ version: 1.0.0-beta.2
+ resolution: "gensync@npm:1.0.0-beta.2"
+ checksum: 10c0/782aba6cba65b1bb5af3b095d96249d20edbe8df32dbf4696fd49be2583faf676173bf4809386588828e4dd76a3354fcbeb577bab1c833ccd9fc4577f26103f8
+ languageName: node
+ linkType: hard
+
+"graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6":
+ version: 4.2.11
+ resolution: "graceful-fs@npm:4.2.11"
+ checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2
+ languageName: node
+ linkType: hard
+
+"isexe@npm:^4.0.0":
+ version: 4.0.0
+ resolution: "isexe@npm:4.0.0"
+ checksum: 10c0/5884815115bceac452877659a9c7726382531592f43dc29e5d48b7c4100661aed54018cb90bd36cb2eaeba521092570769167acbb95c18d39afdccbcca06c5ce
+ languageName: node
+ linkType: hard
+
+"jiti@npm:^2.7.0":
+ version: 2.7.0
+ resolution: "jiti@npm:2.7.0"
+ bin:
+ jiti: lib/jiti-cli.mjs
+ checksum: 10c0/1b1e2310a490dce1aeea3da5f5dfe18273516c20ce48be2e98eb8ea452d5f3dcc8fd0cfd6d28b4052a24c5dbab6e3089b2d7e79f0bce7915b10d750929563c42
+ languageName: node
+ linkType: hard
+
+"js-tokens@npm:^4.0.0":
+ version: 4.0.0
+ resolution: "js-tokens@npm:4.0.0"
+ checksum: 10c0/e248708d377aa058eacf2037b07ded847790e6de892bbad3dac0abba2e759cb9f121b00099a65195616badcb6eca8d14d975cb3e89eb1cfda644756402c8aeed
+ languageName: node
+ linkType: hard
+
+"jsesc@npm:^3.0.2":
+ version: 3.1.0
+ resolution: "jsesc@npm:3.1.0"
+ bin:
+ jsesc: bin/jsesc
+ checksum: 10c0/531779df5ec94f47e462da26b4cbf05eb88a83d9f08aac2ba04206508fc598527a153d08bd462bae82fc78b3eaa1a908e1a4a79f886e9238641c4cdefaf118b1
+ languageName: node
+ linkType: hard
+
+"json5@npm:^2.2.3":
+ version: 2.2.3
+ resolution: "json5@npm:2.2.3"
+ bin:
+ json5: lib/cli.js
+ checksum: 10c0/5a04eed94810fa55c5ea138b2f7a5c12b97c3750bc63d11e511dcecbfef758003861522a070c2272764ee0f4e3e323862f386945aeb5b85b87ee43f084ba586c
+ languageName: node
+ linkType: hard
+
+"lightningcss-android-arm64@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-android-arm64@npm:1.32.0"
+ conditions: os=android & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"lightningcss-android-arm64@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-android-arm64@npm:1.33.0"
+ conditions: os=android & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"lightningcss-darwin-arm64@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-darwin-arm64@npm:1.32.0"
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"lightningcss-darwin-arm64@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-darwin-arm64@npm:1.33.0"
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"lightningcss-darwin-x64@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-darwin-x64@npm:1.32.0"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
+"lightningcss-darwin-x64@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-darwin-x64@npm:1.33.0"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
+"lightningcss-freebsd-x64@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-freebsd-x64@npm:1.32.0"
+ conditions: os=freebsd & cpu=x64
+ languageName: node
+ linkType: hard
+
+"lightningcss-freebsd-x64@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-freebsd-x64@npm:1.33.0"
+ conditions: os=freebsd & cpu=x64
+ languageName: node
+ linkType: hard
+
+"lightningcss-linux-arm-gnueabihf@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-linux-arm-gnueabihf@npm:1.32.0"
+ conditions: os=linux & cpu=arm
+ languageName: node
+ linkType: hard
+
+"lightningcss-linux-arm-gnueabihf@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-linux-arm-gnueabihf@npm:1.33.0"
+ conditions: os=linux & cpu=arm
+ languageName: node
+ linkType: hard
+
+"lightningcss-linux-arm64-gnu@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-linux-arm64-gnu@npm:1.32.0"
+ conditions: os=linux & cpu=arm64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"lightningcss-linux-arm64-gnu@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-linux-arm64-gnu@npm:1.33.0"
+ conditions: os=linux & cpu=arm64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"lightningcss-linux-arm64-musl@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-linux-arm64-musl@npm:1.32.0"
+ conditions: os=linux & cpu=arm64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"lightningcss-linux-arm64-musl@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-linux-arm64-musl@npm:1.33.0"
+ conditions: os=linux & cpu=arm64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"lightningcss-linux-x64-gnu@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-linux-x64-gnu@npm:1.32.0"
+ conditions: os=linux & cpu=x64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"lightningcss-linux-x64-gnu@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-linux-x64-gnu@npm:1.33.0"
+ conditions: os=linux & cpu=x64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"lightningcss-linux-x64-musl@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-linux-x64-musl@npm:1.32.0"
+ conditions: os=linux & cpu=x64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"lightningcss-linux-x64-musl@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-linux-x64-musl@npm:1.33.0"
+ conditions: os=linux & cpu=x64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"lightningcss-win32-arm64-msvc@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-win32-arm64-msvc@npm:1.32.0"
+ conditions: os=win32 & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"lightningcss-win32-arm64-msvc@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-win32-arm64-msvc@npm:1.33.0"
+ conditions: os=win32 & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"lightningcss-win32-x64-msvc@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-win32-x64-msvc@npm:1.32.0"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
+"lightningcss-win32-x64-msvc@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-win32-x64-msvc@npm:1.33.0"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
+"lightningcss@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss@npm:1.32.0"
+ dependencies:
+ detect-libc: "npm:^2.0.3"
+ lightningcss-android-arm64: "npm:1.32.0"
+ lightningcss-darwin-arm64: "npm:1.32.0"
+ lightningcss-darwin-x64: "npm:1.32.0"
+ lightningcss-freebsd-x64: "npm:1.32.0"
+ lightningcss-linux-arm-gnueabihf: "npm:1.32.0"
+ lightningcss-linux-arm64-gnu: "npm:1.32.0"
+ lightningcss-linux-arm64-musl: "npm:1.32.0"
+ lightningcss-linux-x64-gnu: "npm:1.32.0"
+ lightningcss-linux-x64-musl: "npm:1.32.0"
+ lightningcss-win32-arm64-msvc: "npm:1.32.0"
+ lightningcss-win32-x64-msvc: "npm:1.32.0"
+ dependenciesMeta:
+ lightningcss-android-arm64:
+ optional: true
+ lightningcss-darwin-arm64:
+ optional: true
+ lightningcss-darwin-x64:
+ optional: true
+ lightningcss-freebsd-x64:
+ optional: true
+ lightningcss-linux-arm-gnueabihf:
+ optional: true
+ lightningcss-linux-arm64-gnu:
+ optional: true
+ lightningcss-linux-arm64-musl:
+ optional: true
+ lightningcss-linux-x64-gnu:
+ optional: true
+ lightningcss-linux-x64-musl:
+ optional: true
+ lightningcss-win32-arm64-msvc:
+ optional: true
+ lightningcss-win32-x64-msvc:
+ optional: true
+ checksum: 10c0/70945bd55097af46fc9fab7f5ed09cd5869d85940a2acab7ee06d0117004a1d68155708a2d462531cea2fc3c67aefc9333a7068c80b0b78dd404c16838809e03
+ languageName: node
+ linkType: hard
+
+"lightningcss@npm:^1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss@npm:1.33.0"
+ dependencies:
+ detect-libc: "npm:^2.0.3"
+ lightningcss-android-arm64: "npm:1.33.0"
+ lightningcss-darwin-arm64: "npm:1.33.0"
+ lightningcss-darwin-x64: "npm:1.33.0"
+ lightningcss-freebsd-x64: "npm:1.33.0"
+ lightningcss-linux-arm-gnueabihf: "npm:1.33.0"
+ lightningcss-linux-arm64-gnu: "npm:1.33.0"
+ lightningcss-linux-arm64-musl: "npm:1.33.0"
+ lightningcss-linux-x64-gnu: "npm:1.33.0"
+ lightningcss-linux-x64-musl: "npm:1.33.0"
+ lightningcss-win32-arm64-msvc: "npm:1.33.0"
+ lightningcss-win32-x64-msvc: "npm:1.33.0"
+ dependenciesMeta:
+ lightningcss-android-arm64:
+ optional: true
+ lightningcss-darwin-arm64:
+ optional: true
+ lightningcss-darwin-x64:
+ optional: true
+ lightningcss-freebsd-x64:
+ optional: true
+ lightningcss-linux-arm-gnueabihf:
+ optional: true
+ lightningcss-linux-arm64-gnu:
+ optional: true
+ lightningcss-linux-arm64-musl:
+ optional: true
+ lightningcss-linux-x64-gnu:
+ optional: true
+ lightningcss-linux-x64-musl:
+ optional: true
+ lightningcss-win32-arm64-msvc:
+ optional: true
+ lightningcss-win32-x64-msvc:
+ optional: true
+ checksum: 10c0/ce1f8279fbae636dbf37fa6e7385d5f98ed881d72af3362f24afbd4685e19c1fcdfecf17e5dd77f2ebee3d0c23ade276230d85842d07292229a2cffba8ff20a3
+ languageName: node
+ linkType: hard
+
+"lru-cache@npm:^5.1.1":
+ version: 5.1.1
+ resolution: "lru-cache@npm:5.1.1"
+ dependencies:
+ yallist: "npm:^3.0.2"
+ checksum: 10c0/89b2ef2ef45f543011e38737b8a8622a2f8998cddf0e5437174ef8f1f70a8b9d14a918ab3e232cb3ba343b7abddffa667f0b59075b2b80e6b4d63c3de6127482
+ languageName: node
+ linkType: hard
+
+"lucide-react@npm:^1.31.0":
+ version: 1.31.0
+ resolution: "lucide-react@npm:1.31.0"
+ peerDependencies:
+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ checksum: 10c0/c49e451279b46250e117ebe112c7b0b60712912571b7139ce9ea004e7d9125a2684d545a16e62706b2bc5e074cf557c9cc0471b437e0c868f59369324f9a6d5e
+ languageName: node
+ linkType: hard
+
+"magic-string@npm:^0.30.21":
+ version: 0.30.21
+ resolution: "magic-string@npm:0.30.21"
+ dependencies:
+ "@jridgewell/sourcemap-codec": "npm:^1.5.5"
+ checksum: 10c0/299378e38f9a270069fc62358522ddfb44e94244baa0d6a8980ab2a9b2490a1d03b236b447eee309e17eb3bddfa482c61259d47960eb018a904f0ded52780c4a
+ languageName: node
+ linkType: hard
+
+"minipass@npm:^7.0.4, minipass@npm:^7.1.2":
+ version: 7.1.3
+ resolution: "minipass@npm:7.1.3"
+ checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb
+ languageName: node
+ linkType: hard
+
+"minizlib@npm:^3.1.0":
+ version: 3.1.0
+ resolution: "minizlib@npm:3.1.0"
+ dependencies:
+ minipass: "npm:^7.1.2"
+ checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec
+ languageName: node
+ linkType: hard
+
+"motion-dom@npm:^13.0.0":
+ version: 13.0.0
+ resolution: "motion-dom@npm:13.0.0"
+ dependencies:
+ motion-utils: "npm:^13.0.0"
+ checksum: 10c0/3029e249515976f1eea67b26c88cfc6037e3ed3a821400e9e10a211dafad7b70f1d0e9243878dcc3011249e8fd84bc0c587a667fe750c74ad41418a85365f61e
+ languageName: node
+ linkType: hard
+
+"motion-utils@npm:^13.0.0":
+ version: 13.0.0
+ resolution: "motion-utils@npm:13.0.0"
+ checksum: 10c0/4b279ce85f71926d668ff5966feb898b174541d16952a61c7150982388553266602ecd2a76e057530076ff8f38d06672e585639ddc73c41dd1a67d04aaa3662d
+ languageName: node
+ linkType: hard
+
+"motion@npm:^13.0.0":
+ version: 13.1.0
+ resolution: "motion@npm:13.1.0"
+ dependencies:
+ framer-motion: "npm:^13.1.0"
+ tslib: "npm:^2.4.0"
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ react:
+ optional: true
+ react-dom:
+ optional: true
+ checksum: 10c0/2f974b22929945cf96d0164916fb04c470d993ab81ca9006bd590638edb35d04e202dc3f4e61fede4fd81bc9ef9d8a47555d73c776e428a357e09097e35b2750
+ languageName: node
+ linkType: hard
+
+"ms@npm:^2.1.3":
+ version: 2.1.3
+ resolution: "ms@npm:2.1.3"
+ checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48
+ languageName: node
+ linkType: hard
+
+"nanoid@npm:^3.3.17":
+ version: 3.3.18
+ resolution: "nanoid@npm:3.3.18"
+ bin:
+ nanoid: bin/nanoid.cjs
+ checksum: 10c0/b994b4e396730f8be2520923284e2040d61eaee55cc6d4935ef6d38d34bafdc46133eda4d3faea5073bda545aa6079d82b886caeac5c731cf9ac18bcc1301425
+ languageName: node
+ linkType: hard
+
+"node-gyp@npm:latest":
+ version: 13.0.1
+ resolution: "node-gyp@npm:13.0.1"
+ dependencies:
+ env-paths: "npm:^2.2.0"
+ exponential-backoff: "npm:^3.1.1"
+ graceful-fs: "npm:^4.2.6"
+ nopt: "npm:^10.0.0"
+ proc-log: "npm:^7.0.0"
+ semver: "npm:^7.3.5"
+ tar: "npm:^7.5.4"
+ tinyglobby: "npm:^0.2.12"
+ undici: "npm:^8.4.1"
+ which: "npm:^7.0.0"
+ bin:
+ node-gyp: bin/node-gyp.js
+ checksum: 10c0/424077bc9e9bbe953a8e86db473ba818cbc6a121714008c977fd589e21e5f0c811fbf22faac730dc7182450b5e52df301811d01ae3373898658d999b7710f4e6
+ languageName: node
+ linkType: hard
+
+"node-releases@npm:^2.0.53":
+ version: 2.0.53
+ resolution: "node-releases@npm:2.0.53"
+ checksum: 10c0/db9fb29b21ec12e223ead8bd9406100ff14fce01678a9a4865e288098456b84869431421a739f216aa520b6f57d9425f6537662501ae8a2d9e1771bfa87751bc
+ languageName: node
+ linkType: hard
+
+"nopt@npm:^10.0.0":
+ version: 10.0.1
+ resolution: "nopt@npm:10.0.1"
+ dependencies:
+ abbrev: "npm:^5.0.0"
+ bin:
+ nopt: bin/nopt.js
+ checksum: 10c0/980d89257f9587f3e1f77877ddbf905d6aa3b738ec33e49a4fa1a059a0dd82eb28063982b150654a7ae9de386f2ead60e56172db7d37cf56de545f7392a2a26a
+ languageName: node
+ linkType: hard
+
+"obug@npm:^2.1.1":
+ version: 2.1.4
+ resolution: "obug@npm:2.1.4"
+ checksum: 10c0/34a0ee97cd88573cfd97d384c2a79f07118ae5680d7e45d1de6e99c74eddefe145e8ca27a2db02195a1ee5fded5aa22b924869c842728c201b9f109a27d0ef19
+ languageName: node
+ linkType: hard
+
+"pathe@npm:^2.0.3":
+ version: 2.0.3
+ resolution: "pathe@npm:2.0.3"
+ checksum: 10c0/c118dc5a8b5c4166011b2b70608762e260085180bb9e33e80a50dcdb1e78c010b1624f4280c492c92b05fc276715a4c357d1f9edc570f8f1b3d90b6839ebaca1
+ languageName: node
+ linkType: hard
+
+"picocolors@npm:^1.1.1":
+ version: 1.1.1
+ resolution: "picocolors@npm:1.1.1"
+ checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58
+ languageName: node
+ linkType: hard
+
+"picomatch@npm:^4.0.3, picomatch@npm:^4.0.4, picomatch@npm:^4.0.5":
+ version: 4.0.5
+ resolution: "picomatch@npm:4.0.5"
+ checksum: 10c0/947bc6b6e1ff1e6c5aaf95b107a0839d12802f4f7b867663f67d47accba939ca1cb582cf99dfc30438efa1c4648ac5990967e783e8929c36b03e8440704ef1bd
+ languageName: node
+ linkType: hard
+
+"postcss@npm:^8.5.25":
+ version: 8.5.26
+ resolution: "postcss@npm:8.5.26"
+ dependencies:
+ nanoid: "npm:^3.3.17"
+ picocolors: "npm:^1.1.1"
+ source-map-js: "npm:^1.2.1"
+ checksum: 10c0/2bdafc00d96bd57b6649a52e458864a4bf58ee56cfdbe4aea1472b5cccc127e6c1ad653bd0bec50d211e650eb0b9270c80e1e72aff2e2fa40d9e7363234d6e43
+ languageName: node
+ linkType: hard
+
+"prettier@npm:^3.8.1":
+ version: 3.9.6
+ resolution: "prettier@npm:3.9.6"
+ bin:
+ prettier: bin/prettier.cjs
+ checksum: 10c0/9f7ddae234035868f3daab3dc34e6de7e54622185afb017378029f0c09a9c023248ccf6f34def0b17a0538e18c47aa1b3188bab72965d1bf735919baa0747e99
+ languageName: node
+ linkType: hard
+
+"proc-log@npm:^7.0.0":
+ version: 7.0.0
+ resolution: "proc-log@npm:7.0.0"
+ checksum: 10c0/b89c2d862604f35fec795477b0c7e376feab3ba0d4f4d291c4e959567442697cf451ac557d0623c1cc38af45a78128b983410f397a10c5d3a67f76c33de4754b
+ languageName: node
+ linkType: hard
+
+"react-dom@npm:19.2.3":
+ version: 19.2.3
+ resolution: "react-dom@npm:19.2.3"
+ dependencies:
+ scheduler: "npm:^0.27.0"
+ peerDependencies:
+ react: ^19.2.3
+ checksum: 10c0/dc43f7ede06f46f3acc16ee83107c925530de9b91d1d0b3824583814746ff4c498ea64fd65cd83aba363205268adff52e2827c582634ae7b15069deaeabc4892
+ languageName: node
+ linkType: hard
+
+"react@npm:19.2.3":
+ version: 19.2.3
+ resolution: "react@npm:19.2.3"
+ checksum: 10c0/094220b3ba3a76c1b668f972ace1dd15509b157aead1b40391d1c8e657e720c201d9719537375eff08f5e0514748c0319063392a6f000e31303aafc4471f1436
+ languageName: node
+ linkType: hard
+
+"reselect@npm:^5.2.0":
+ version: 5.2.0
+ resolution: "reselect@npm:5.2.0"
+ checksum: 10c0/d0a8497e404b25a769fe792eacae88b9b576c30870450cd243d76ec9427d91a59c4a2cc00d824d283448b444c4c698a8f961d771c8ae39c759313afb31950e2e
+ languageName: node
+ linkType: hard
+
+"rolldown@npm:^1.0.0-rc.5, rolldown@npm:~1.2.1":
+ version: 1.2.4
+ resolution: "rolldown@npm:1.2.4"
+ dependencies:
+ "@oxc-project/types": "npm:=0.144.0"
+ "@rolldown/binding-android-arm64": "npm:1.2.4"
+ "@rolldown/binding-darwin-arm64": "npm:1.2.4"
+ "@rolldown/binding-darwin-x64": "npm:1.2.4"
+ "@rolldown/binding-freebsd-x64": "npm:1.2.4"
+ "@rolldown/binding-linux-arm-gnueabihf": "npm:1.2.4"
+ "@rolldown/binding-linux-arm64-gnu": "npm:1.2.4"
+ "@rolldown/binding-linux-arm64-musl": "npm:1.2.4"
+ "@rolldown/binding-linux-ppc64-gnu": "npm:1.2.4"
+ "@rolldown/binding-linux-s390x-gnu": "npm:1.2.4"
+ "@rolldown/binding-linux-x64-gnu": "npm:1.2.4"
+ "@rolldown/binding-linux-x64-musl": "npm:1.2.4"
+ "@rolldown/binding-openharmony-arm64": "npm:1.2.4"
+ "@rolldown/binding-win32-arm64-msvc": "npm:1.2.4"
+ "@rolldown/binding-win32-x64-msvc": "npm:1.2.4"
+ "@rolldown/pluginutils": "npm:^1.0.0"
+ dependenciesMeta:
+ "@rolldown/binding-android-arm64":
+ optional: true
+ "@rolldown/binding-darwin-arm64":
+ optional: true
+ "@rolldown/binding-darwin-x64":
+ optional: true
+ "@rolldown/binding-freebsd-x64":
+ optional: true
+ "@rolldown/binding-linux-arm-gnueabihf":
+ optional: true
+ "@rolldown/binding-linux-arm64-gnu":
+ optional: true
+ "@rolldown/binding-linux-arm64-musl":
+ optional: true
+ "@rolldown/binding-linux-ppc64-gnu":
+ optional: true
+ "@rolldown/binding-linux-s390x-gnu":
+ optional: true
+ "@rolldown/binding-linux-x64-gnu":
+ optional: true
+ "@rolldown/binding-linux-x64-musl":
+ optional: true
+ "@rolldown/binding-openharmony-arm64":
+ optional: true
+ "@rolldown/binding-win32-arm64-msvc":
+ optional: true
+ "@rolldown/binding-win32-x64-msvc":
+ optional: true
+ bin:
+ rolldown: ./bin/cli.mjs
+ checksum: 10c0/438c2222db940fab6e2db1f1cf84ab27c83310959e056fa389d7e2208bcbf58929f970f4dca5bd9c255f319bf657ef1a4a7ea6725213491d28764d867e5407a4
+ languageName: node
+ linkType: hard
+
+"scheduler@npm:^0.27.0":
+ version: 0.27.0
+ resolution: "scheduler@npm:0.27.0"
+ checksum: 10c0/4f03048cb05a3c8fddc45813052251eca00688f413a3cee236d984a161da28db28ba71bd11e7a3dd02f7af84ab28d39fb311431d3b3772fed557945beb00c452
+ languageName: node
+ linkType: hard
+
+"semver@npm:^6.3.1":
+ version: 6.3.1
+ resolution: "semver@npm:6.3.1"
+ bin:
+ semver: bin/semver.js
+ checksum: 10c0/e3d79b609071caa78bcb6ce2ad81c7966a46a7431d9d58b8800cfa9cb6a63699b3899a0e4bcce36167a284578212d9ae6942b6929ba4aa5015c079a67751d42d
+ languageName: node
+ linkType: hard
+
+"semver@npm:^7.3.5":
+ version: 7.8.5
+ resolution: "semver@npm:7.8.5"
+ bin:
+ semver: bin/semver.js
+ checksum: 10c0/b1f3127a5be8125a94f37188b361c212466c292c6910adce3ec106cff5dc211ccaedc4739c11bb70fda59d6fc1f040a9bca289f4e093451521a2372e5231fe0c
+ languageName: node
+ linkType: hard
+
+"siginfo@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "siginfo@npm:2.0.0"
+ checksum: 10c0/3def8f8e516fbb34cb6ae415b07ccc5d9c018d85b4b8611e3dc6f8be6d1899f693a4382913c9ed51a06babb5201639d76453ab297d1c54a456544acf5c892e34
+ languageName: node
+ linkType: hard
+
+"source-map-js@npm:^1.2.1":
+ version: 1.2.1
+ resolution: "source-map-js@npm:1.2.1"
+ checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf
+ languageName: node
+ linkType: hard
+
+"stackback@npm:0.0.2":
+ version: 0.0.2
+ resolution: "stackback@npm:0.0.2"
+ checksum: 10c0/89a1416668f950236dd5ac9f9a6b2588e1b9b62b1b6ad8dff1bfc5d1a15dbf0aafc9b52d2226d00c28dffff212da464eaeebfc6b7578b9d180cef3e3782c5983
+ languageName: node
+ linkType: hard
+
+"std-env@npm:^4.0.0-rc.1":
+ version: 4.2.0
+ resolution: "std-env@npm:4.2.0"
+ checksum: 10c0/40ac525ce7b7c556abc332a7376f14356eeb1a7f17f6ff9a003eb9f52326ff1f3745d3e1b43452675b1ec6fcc319f1b1d6f3b0d386cf3f91058479ad883cff69
+ languageName: node
+ linkType: hard
+
+"tailwind-merge@npm:^3.6.0":
+ version: 3.6.0
+ resolution: "tailwind-merge@npm:3.6.0"
+ checksum: 10c0/a728d929f4786bcb48641db4228cc8c006a7545491783a658fb4d24d177dab960620991d17805a047309228de50ab949ca8d9e112c30183da0432367796ae186
+ languageName: node
+ linkType: hard
+
+"tailwindcss@npm:4.3.3, tailwindcss@npm:^4.3.3":
+ version: 4.3.3
+ resolution: "tailwindcss@npm:4.3.3"
+ checksum: 10c0/7e9cd7553cd890ffa5350efb0ca8971ba5435257d0f98bf0714994ac8f1fee5c7cdf9a9da47fe83e8ac995d728410837ac49d3ef5ea66b5afe1db312086ffae2
+ languageName: node
+ linkType: hard
+
+"tapable@npm:^2.3.3":
+ version: 2.3.3
+ resolution: "tapable@npm:2.3.3"
+ checksum: 10c0/47992e861053f861154e92fb4a98ac4ab47b6463717e60792dd1e8c755da0c4964cd8bb68c308a9066d6da89000b6310457b4d5d985c30de4ccc29066068cc17
+ languageName: node
+ linkType: hard
+
+"tar@npm:^7.5.4":
+ version: 7.5.22
+ resolution: "tar@npm:7.5.22"
+ dependencies:
+ "@isaacs/fs-minipass": "npm:^4.0.0"
+ chownr: "npm:^3.0.0"
+ minipass: "npm:^7.1.2"
+ minizlib: "npm:^3.1.0"
+ yallist: "npm:^5.0.0"
+ checksum: 10c0/1311f6be85a8157ac4c9147bae43e13923d2a1aae15e4aa1bd5239e4e03d2cf53cfe103dde7f35832fbb4c938b042856bc8e9a0afd29abd05e2d1608788c4fea
+ languageName: node
+ linkType: hard
+
+"tinybench@npm:^2.9.0":
+ version: 2.9.0
+ resolution: "tinybench@npm:2.9.0"
+ checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c
+ languageName: node
+ linkType: hard
+
+"tinyexec@npm:^1.0.2":
+ version: 1.3.0
+ resolution: "tinyexec@npm:1.3.0"
+ checksum: 10c0/e9b89f97489d2aab2cef408da279e6b32547e738d1275032ccb8fd0028a006d93eb70fc51c6cffd9fc2f5aca6c2a273d8b6f73b52d46ee5116da6b94969ef958
+ languageName: node
+ linkType: hard
+
+"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15, tinyglobby@npm:^0.2.17":
+ version: 0.2.17
+ resolution: "tinyglobby@npm:0.2.17"
+ dependencies:
+ fdir: "npm:^6.5.0"
+ picomatch: "npm:^4.0.4"
+ checksum: 10c0/7f7bb0f197c88bc4b20c231e0deca4240ca3bf313a88f5a7fee93a872b84966a4d50220947c0455ad07a60b3b360961c5b7fd979222aeb716a9f99b412002e4c
+ languageName: node
+ linkType: hard
+
+"tinyrainbow@npm:^3.1.0":
+ version: 3.1.1
+ resolution: "tinyrainbow@npm:3.1.1"
+ checksum: 10c0/f9d2743832c6191f753408f36224fe817620b8abcef572b2e570204c673a901d753ff84ca8e7b88f9c79e934295b3ffc6fcbc56a06f126e24e1ec6186dcad40d
+ languageName: node
+ linkType: hard
+
+"tslib@npm:^2.4.0, tslib@npm:^2.8.1":
+ version: 2.8.1
+ resolution: "tslib@npm:2.8.1"
+ checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62
+ languageName: node
+ linkType: hard
+
+"typescript@npm:~6.0.3":
+ version: 6.0.3
+ resolution: "typescript@npm:6.0.3"
+ bin:
+ tsc: bin/tsc
+ tsserver: bin/tsserver
+ checksum: 10c0/4a25ff5045b984370f48f196b3a0120779b1b343d40b9a68d114ea5e5fff099809b2bb777576991a63a5cd59cf7bffd96ff6fe10afcefbcb8bd6fb96ad4b6606
+ languageName: node
+ linkType: hard
+
+"typescript@patch:typescript@npm%3A~6.0.3#optional!builtin":
+ version: 6.0.3
+ resolution: "typescript@patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5"
+ bin:
+ tsc: bin/tsc
+ tsserver: bin/tsserver
+ checksum: 10c0/2f25c74e65663c248fa1ade2b8459d9ce5372ff9dad07067310f132966ebec1d93f6c42f0baf77a6b6a7a91460463f708e6887013aaade22111037457c6b25df
+ languageName: node
+ linkType: hard
+
+"undici-types@npm:~7.18.0":
+ version: 7.18.2
+ resolution: "undici-types@npm:7.18.2"
+ checksum: 10c0/85a79189113a238959d7a647368e4f7c5559c3a404ebdb8fc4488145ce9426fcd82252a844a302798dfc0e37e6fb178ff481ed03bc4caf634c5757d9ef43521d
+ languageName: node
+ linkType: hard
+
+"undici@npm:^8.4.1":
+ version: 8.10.0
+ resolution: "undici@npm:8.10.0"
+ checksum: 10c0/37ae2b1db8f65c3a003504e2040e96887b99f9db16ba07dcf49c37ed8b7f8c72f4452f2d46f52f7c9e49518fe8325e3b5e7e02ac3a2424886bd67d4b62a0ecab
+ languageName: node
+ linkType: hard
+
+"update-browserslist-db@npm:^1.3.0":
+ version: 1.3.1
+ resolution: "update-browserslist-db@npm:1.3.1"
+ dependencies:
+ escalade: "npm:^3.2.0"
+ picocolors: "npm:^1.1.1"
+ peerDependencies:
+ browserslist: ">= 4.21.0"
+ bin:
+ update-browserslist-db: cli.js
+ checksum: 10c0/2a924f5aa283af2d918e5cd941c7e8d77b05adf2ffbe5ca461506b1ff05fee6e8bca74c0f1ba37eb28f00ec93b15e80de24c7222825835aec2dd7bf0a0f6a417
+ languageName: node
+ linkType: hard
+
+"use-sync-external-store@npm:^1.6.0":
+ version: 1.6.0
+ resolution: "use-sync-external-store@npm:1.6.0"
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ checksum: 10c0/35e1179f872a53227bdf8a827f7911da4c37c0f4091c29b76b1e32473d1670ebe7bcd880b808b7549ba9a5605c233350f800ffab963ee4a4ee346ee983b6019b
+ languageName: node
+ linkType: hard
+
+"vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0, vite@npm:^8.0.13":
+ version: 8.2.1
+ resolution: "vite@npm:8.2.1"
+ dependencies:
+ fsevents: "npm:~2.3.3"
+ lightningcss: "npm:^1.33.0"
+ picomatch: "npm:^4.0.5"
+ postcss: "npm:^8.5.25"
+ rolldown: "npm:~1.2.1"
+ tinyglobby: "npm:^0.2.17"
+ peerDependencies:
+ "@types/node": ^20.19.0 || >=22.12.0
+ "@vitejs/devtools": ^0.4.0
+ esbuild: ^0.27.0 || ^0.28.0
+ jiti: ">=1.21.0"
+ less: ^4.0.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: ">=0.54.8"
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ dependenciesMeta:
+ fsevents:
+ optional: true
+ peerDependenciesMeta:
+ "@types/node":
+ optional: true
+ "@vitejs/devtools":
+ optional: true
+ esbuild:
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+ bin:
+ vite: bin/vite.js
+ checksum: 10c0/e958dd07502deeb552f04dba59418ff1eb63183add416fd7a3e3badabf5d36edc6834b94c916f9f9233f976bb1671e3e9989d90e869059af07b0d43f7fc078c2
+ languageName: node
+ linkType: hard
+
+"vitest@npm:^4.1.10":
+ version: 4.1.10
+ resolution: "vitest@npm:4.1.10"
+ dependencies:
+ "@vitest/expect": "npm:4.1.10"
+ "@vitest/mocker": "npm:4.1.10"
+ "@vitest/pretty-format": "npm:4.1.10"
+ "@vitest/runner": "npm:4.1.10"
+ "@vitest/snapshot": "npm:4.1.10"
+ "@vitest/spy": "npm:4.1.10"
+ "@vitest/utils": "npm:4.1.10"
+ es-module-lexer: "npm:^2.0.0"
+ expect-type: "npm:^1.3.0"
+ magic-string: "npm:^0.30.21"
+ obug: "npm:^2.1.1"
+ pathe: "npm:^2.0.3"
+ picomatch: "npm:^4.0.3"
+ std-env: "npm:^4.0.0-rc.1"
+ tinybench: "npm:^2.9.0"
+ tinyexec: "npm:^1.0.2"
+ tinyglobby: "npm:^0.2.15"
+ tinyrainbow: "npm:^3.1.0"
+ vite: "npm:^6.0.0 || ^7.0.0 || ^8.0.0"
+ why-is-node-running: "npm:^2.3.0"
+ peerDependencies:
+ "@edge-runtime/vm": "*"
+ "@opentelemetry/api": ^1.9.0
+ "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0
+ "@vitest/browser-playwright": 4.1.10
+ "@vitest/browser-preview": 4.1.10
+ "@vitest/browser-webdriverio": 4.1.10
+ "@vitest/coverage-istanbul": 4.1.10
+ "@vitest/coverage-v8": 4.1.10
+ "@vitest/ui": 4.1.10
+ happy-dom: "*"
+ jsdom: "*"
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ "@edge-runtime/vm":
+ optional: true
+ "@opentelemetry/api":
+ optional: true
+ "@types/node":
+ optional: true
+ "@vitest/browser-playwright":
+ optional: true
+ "@vitest/browser-preview":
+ optional: true
+ "@vitest/browser-webdriverio":
+ optional: true
+ "@vitest/coverage-istanbul":
+ optional: true
+ "@vitest/coverage-v8":
+ optional: true
+ "@vitest/ui":
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+ vite:
+ optional: false
+ bin:
+ vitest: ./vitest.mjs
+ checksum: 10c0/ff07294a57f9c62f3b503f7cf88a52ee0753ed26389a49cda430387a3898f39d80af47180b0af19e27acab5bd11ae95706bd4b44ce8befc97d3ae49af6ca4fc1
+ languageName: node
+ linkType: hard
+
+"which@npm:^7.0.0":
+ version: 7.0.0
+ resolution: "which@npm:7.0.0"
+ dependencies:
+ isexe: "npm:^4.0.0"
+ bin:
+ node-which: bin/which.js
+ checksum: 10c0/ca0b54f198f78bbc4b7c02e34bda8d335cb352e0adb4cbca1c37b1a957af3a879a82c4c27ca6525bc942f548d8b64f816ef6528360af9f3de55ffb9b979b620d
+ languageName: node
+ linkType: hard
+
+"why-is-node-running@npm:^2.3.0":
+ version: 2.3.0
+ resolution: "why-is-node-running@npm:2.3.0"
+ dependencies:
+ siginfo: "npm:^2.0.0"
+ stackback: "npm:0.0.2"
+ bin:
+ why-is-node-running: cli.js
+ checksum: 10c0/1cde0b01b827d2cf4cb11db962f3958b9175d5d9e7ac7361d1a7b0e2dc6069a263e69118bd974c4f6d0a890ef4eedfe34cf3d5167ec14203dbc9a18620537054
+ languageName: node
+ linkType: hard
+
+"yallist@npm:^3.0.2":
+ version: 3.1.1
+ resolution: "yallist@npm:3.1.1"
+ checksum: 10c0/c66a5c46bc89af1625476f7f0f2ec3653c1a1791d2f9407cfb4c2ba812a1e1c9941416d71ba9719876530e3340a99925f697142989371b72d93b9ee628afd8c1
+ languageName: node
+ linkType: hard
+
+"yallist@npm:^5.0.0":
+ version: 5.0.0
+ resolution: "yallist@npm:5.0.0"
+ checksum: 10c0/a499c81ce6d4a1d260d4ea0f6d49ab4da09681e32c3f0472dee16667ed69d01dae63a3b81745a24bd78476ec4fcf856114cb4896ace738e01da34b2c42235416
+ languageName: node
+ linkType: hard