feat: import standalone Avatar Lab

This commit is contained in:
smontlouis 2026-08-13 18:31:27 +02:00
parent 8913438acd
commit e547bc9f7b
75 changed files with 26466 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
dist/
node_modules/
.yarn/
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
*.local
.DS_Store
coverage/

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
22.12.0

6
.prettierignore Normal file
View File

@ -0,0 +1,6 @@
dist/
node_modules/
legacy/
src/defaultStudioDocument.json
src/standaloneEngine.generated.ts
yarn.lock

10
.prettierrc Normal file
View File

@ -0,0 +1,10 @@
{
"trailingComma": "es5",
"tabWidth": 2,
"semi": false,
"singleQuote": true,
"jsxSingleQuote": false,
"bracketSpacing": true,
"arrowParens": "avoid",
"printWidth": 100
}

1
.yarnrc.yml Normal file
View File

@ -0,0 +1 @@
nodeLinker: node-modules

34
AGENTS.md Normal file
View File

@ -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.

68
CONTEXT.md Normal file
View File

@ -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/`.

51
README.md Normal file
View File

@ -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.

22
components.json Normal file
View File

@ -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": {}
}

View File

@ -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.

View File

@ -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.

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<title>Bible Strong Avatar Lab</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4015
legacy/index.html Normal file

File diff suppressed because it is too large Load Diff

2894
legacy/spherical-eyes.html Normal file

File diff suppressed because it is too large Load Diff

50
package.json Normal file
View File

@ -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"
}
}

View File

@ -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)
}

6015
src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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 })
})
})

View File

@ -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)
})
})

View File

@ -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
)
})
})

View File

@ -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('<html lang="fr">')
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: '</title><script>alert(1)</script>' },
})
expect(source).not.toContain('</title><script>alert(1)</script>')
expect(source).toContain('&lt;/title&gt;')
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<AvatarHandle, AvatarProps>')
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<AnimationName>')
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')
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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.'
)
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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')
})
})

View File

@ -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)
})
})

View File

@ -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('<rect')
})
it('embeds a radial background without external dependencies', () => {
const svg = serializeAvatarSnapshot('Strobi', scene, colors, {
background: 'radial',
colorFrom: '#ffffff',
colorTo: '#8899aa',
size: 512,
})
expect(svg).toContain('<radialGradient id="snapshot-radial"')
expect(svg).toContain('fill="url(#snapshot-radial)"')
expect(snapshotFileName('Étoile du soir')).toBe('etoile-du-soir-snapshot.svg')
expect(snapshotFileName('Étoile du soir', 'png')).toBe('etoile-du-soir-snapshot.png')
})
})

View File

@ -0,0 +1,171 @@
import { createAvatar } from '../avatars'
import { createInitialSequences } from '../sequences'
import { initialExpressions } from '../presets'
import {
createStudioDocumentStore,
loadStudioDocument,
parseImportedStudioDocument,
serializeStudioDocument,
type StudioDocument,
} from '../studioDocument'
const documentFixture = (): StudioDocument => {
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)
})
})

96
src/ambientMotion.ts Normal file
View File

@ -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<string>(eyeMotionModes)
const bodyMotionSet = new Set<string>(bodyMotionModes)
export const isEyeMotion = (value: unknown): value is EyeMotion =>
typeof value === 'string' && eyeMotionSet.has(value)
export const isBodyMotion = (value: unknown): value is BodyMotion =>
typeof value === 'string' && bodyMotionSet.has(value)
const smoothstep = (value: number) => value * value * (3 - 2 * value)
const hash = (value: number) => {
const raw = Math.sin(value * 127.1 + 311.7) * 43758.5453
return (raw - Math.floor(raw)) * 2 - 1
}
const expressionSeed = (expression: Expression) =>
expression.headX * 0.71 + expression.headY * 1.13 + expression.headZ * 1.37
const 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
}

211
src/avatars.ts Normal file
View File

@ -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<AvatarColors> | 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<AvatarEyeDefaults> | 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<Expression>
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<AvatarBehaviorLibrary>
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<AvatarLibrary> | null
if (!parsed || !Array.isArray(parsed.avatars) || !parsed.avatars.length) return fallback
const seenIds = new Set<string>()
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
}
}

117
src/body.ts Normal file
View File

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

12
src/canvasPreview.ts Normal file
View File

@ -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

View File

@ -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 (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn('grid gap-3', className)}
{...props}
/>
)
}
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn('overflow-hidden rounded-2xl border bg-card', className)}
{...props}
/>
)
}
function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) {
return (
<AccordionPrimitive.Header data-slot="accordion-header">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
'flex w-full items-center justify-between gap-4 p-5 text-left outline-none transition-colors hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50',
className
)}
{...props}
>
{children}
<ChevronDown className="size-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({ className, children, ...props }: AccordionPrimitive.Panel.Props) {
return (
<AccordionPrimitive.Panel
data-slot="accordion-content"
className={cn('h-[var(--accordion-panel-height)] overflow-hidden', className)}
{...props}
>
<div className="px-5 pb-5">{children}</div>
</AccordionPrimitive.Panel>
)
}
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }

View File

@ -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 <AlertDialogPrimitive.Root {...props} />
}
function AlertDialogTrigger(props: AlertDialogPrimitive.Trigger.Props) {
return <AlertDialogPrimitive.Trigger {...props} />
}
function AlertDialogContent({ className, ...props }: AlertDialogPrimitive.Popup.Props) {
return (
<AlertDialogPrimitive.Portal>
<AlertDialogPrimitive.Backdrop className="fixed inset-0 z-50 bg-black/30 backdrop-blur-sm" />
<AlertDialogPrimitive.Popup
className={cn(
'fixed top-1/2 left-1/2 z-50 grid w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-5 text-popover-foreground shadow-2xl ring-1 ring-foreground/10 outline-none',
className
)}
{...props}
/>
</AlertDialogPrimitive.Portal>
)
}
function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('grid gap-1.5', className)} {...props} />
}
function AlertDialogTitle({ className, ...props }: AlertDialogPrimitive.Title.Props) {
return (
<AlertDialogPrimitive.Title className={cn('text-base font-semibold', className)} {...props} />
)
}
function AlertDialogDescription({ className, ...props }: AlertDialogPrimitive.Description.Props) {
return (
<AlertDialogPrimitive.Description
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)
}
function AlertDialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('flex justify-end gap-2', className)} {...props} />
}
function AlertDialogCancel(props: AlertDialogPrimitive.Close.Props) {
return <AlertDialogPrimitive.Close render={<Button variant="outline" />} {...props} />
}
function AlertDialogAction(props: React.ComponentProps<typeof Button>) {
return <Button variant="destructive" {...props} />
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@ -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<typeof badgeVariants>) {
return (
<span
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -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<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,56 @@
import type * as React from 'react'
import { cn } from '../../lib/utils'
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm',
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-header" className={cn('grid gap-2 px-6', className)} {...props} />
}
function CardTitle({
as: Component = 'div',
className,
...props
}: React.ComponentProps<'div'> & { as?: 'div' | 'h2' | 'h3' }) {
return (
<Component
data-slot="card-title"
className={cn('font-semibold leading-none', className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot="card-footer" className={cn('flex items-center px-6', className)} {...props} />
)
}
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }

View File

@ -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 <ContextMenuPrimitive.Root {...props} />
}
function ContextMenuTrigger(props: ContextMenuPrimitive.Trigger.Props) {
return <ContextMenuPrimitive.Trigger {...props} />
}
function ContextMenuContent({ className, ...props }: ContextMenuPrimitive.Popup.Props) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Positioner className="z-50">
<ContextMenuPrimitive.Popup
className={cn(
'min-w-40 rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-xl outline-none',
className
)}
{...props}
/>
</ContextMenuPrimitive.Positioner>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
variant = 'default',
...props
}: ContextMenuPrimitive.Item.Props & { variant?: 'default' | 'destructive' }) {
return (
<ContextMenuPrimitive.Item
className={cn(
'flex cursor-default items-center gap-2 rounded-md px-2.5 py-2 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-muted',
variant === 'destructive' && 'text-destructive',
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({ className, ...props }: React.ComponentProps<'div'>) {
return (
<ContextMenuPrimitive.Separator
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
}

View File

@ -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<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-group"
className={cn('flex w-full flex-col gap-7', className)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-label"
className={cn('flex items-center gap-2 text-sm font-medium leading-snug', className)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="field-description"
className={cn('text-sm font-normal leading-normal text-muted-foreground', className)}
{...props}
/>
)
}
export { Field, FieldDescription, FieldGroup, FieldTitle }

View File

@ -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 (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50',
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -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<Value, Multiple extends boolean | undefined = false>(
props: SelectPrimitive.Root.Props<Value, Multiple>
) {
return <SelectPrimitive.Root {...props} />
}
function SelectTrigger({ className, children, ...props }: SelectPrimitive.Trigger.Props) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
className={cn(
'flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/20 disabled:pointer-events-none disabled:opacity-50',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon render={<ChevronDown className="size-4 shrink-0 opacity-50" />} />
</SelectPrimitive.Trigger>
)
}
function SelectValue(props: SelectPrimitive.Value.Props) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectContent({ className, children, ...props }: SelectPrimitive.Popup.Props) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner className="z-50" sideOffset={4} alignItemWithTrigger={false}>
<SelectPrimitive.Popup
data-slot="select-content"
className={cn(
'min-w-[var(--anchor-width)] rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none',
className
)}
{...props}
>
<SelectPrimitive.List>{children}</SelectPrimitive.List>
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
'relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-muted',
className
)}
{...props}
>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator className="absolute right-2 flex size-4 items-center justify-center">
<Check className="size-4" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue }

View File

@ -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<typeof SeparatorPrimitive> & { orientation?: 'horizontal' | 'vertical' }) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className
)}
{...props}
/>
)
}
export { Separator }

View File

@ -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<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
'group/switch inline-flex h-5 w-8 shrink-0 items-center rounded-full border border-transparent bg-input outline-none transition-colors focus-visible:ring-3 focus-visible:ring-ring/50 data-[checked]:bg-primary disabled:opacity-50',
className
)}
{...props}
>
<SwitchPrimitive.Thumb className="block size-4 translate-x-0 rounded-full bg-background shadow-sm transition-transform group-data-[checked]/switch:translate-x-3" />
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@ -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 (
<TabsPrimitive.Root
data-slot="tabs"
className={cn('flex flex-col gap-2', className)}
{...props}
/>
)
}
function TabsList({ className, ...props }: TabsPrimitive.List.Props) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1',
className
)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
'inline-flex h-7 flex-1 items-center justify-center rounded-md px-3 text-sm font-medium text-muted-foreground transition-all outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 data-active:bg-background data-active:text-foreground data-active:shadow-sm',
className
)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger }

View File

@ -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 <TooltipPrimitive.Provider delay={250} {...props} />
}
function Tooltip(props: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root {...props} />
}
function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger {...props} />
}
function TooltipContent({
className,
children,
side = 'top',
sideOffset = 6,
...props
}: TooltipPrimitive.Popup.Props & Pick<TooltipPrimitive.Positioner.Props, 'side' | 'sideOffset'>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner side={side} sideOffset={sideOffset} className="z-50">
<TooltipPrimitive.Popup
className={cn(
'rounded-md bg-foreground px-2.5 py-1.5 text-xs text-background shadow-md',
className
)}
{...props}
>
{children}
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

File diff suppressed because it is too large Load Diff

547
src/exporter.ts Normal file
View File

@ -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<string, Expression>
animations: Record<string, AvatarExportAnimation>
}
const animationKey = (animation: AvatarSequence, used: Set<string>) => {
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<string>()
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(/</g, '\\u003c')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029')
const escapedHtml = (value: string) =>
value.replace(/[&<>"']/g, character => {
const entities: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}
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 `<!doctype html>
<html lang="${language}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escapedHtml(payload.avatar.name)} · ${copy.title}</title>
<style>
* { box-sizing: border-box; }
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
body { margin: 0; min-height: 100vh; color: #f7f8fa; background: #0d1117; }
button { font: inherit; }
.playground { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 28px 0; }
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 22px; }
.identity { display: flex; align-items: center; gap: 12px; }
.brand-mark { width: 13px; height: 13px; border-radius: 50%; background: #5b7fe5; box-shadow: 0 0 0 7px rgb(91 127 229 / 12%); }
.eyebrow { margin: 0 0 3px; color: #7f9cff; font-size: 11px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(20px, 3vw, 28px); letter-spacing: -.035em; }
.status { display: inline-flex; align-items: center; gap: 8px; min-height: 34px; padding: 0 12px; border: 1px solid #293140; border-radius: 999px; color: #aeb8c7; background: #141a22; font-size: 12px; font-weight: 700; }
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: #47cf8a; box-shadow: 0 0 0 4px rgb(71 207 138 / 10%); }
.status[data-state="paused"] .status-dot { background: #f4b860; box-shadow: 0 0 0 4px rgb(244 184 96 / 10%); }
.status[data-state="stopped"] .status-dot { background: #788395; box-shadow: none; }
.workspace { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(310px, .65fr); gap: 18px; }
.preview-card, .control-card { border: 1px solid #252d39; border-radius: 24px; overflow: hidden; box-shadow: 0 24px 80px rgb(0 0 0 / 22%); }
.preview-card { min-height: min(680px, calc(100vh - 130px)); display: grid; place-items: center; position: relative; background: radial-gradient(circle at 50% 42%, #1a2432 0, #111821 54%, #0b1016 100%); }
.preview-card::after { content: "${copy.previewLabel}"; position: absolute; left: 20px; bottom: 18px; color: #667285; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; }
#avatar { width: min(72%, 520px); aspect-ratio: 1; }
.control-card { align-self: stretch; padding: 22px; color: #171a20; background: #f7f8fa; }
.section + .section { margin-top: 22px; padding-top: 22px; border-top: 1px solid #dde1e8; }
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 12px; margin-bottom: 13px; }
h2 { margin: 0; font-size: 15px; letter-spacing: -.015em; }
.section-copy { margin: 4px 0 0; color: #717a89; font-size: 12px; line-height: 1.45; }
#active-animation { color: #5277dc; font-size: 12px; font-weight: 800; }
.animation-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; max-height: 250px; overflow: auto; padding: 2px; }
.animation-button { min-width: 0; padding: 10px 11px; overflow: hidden; color: #424a57; border: 1px solid #d8dde6; border-radius: 11px; background: #fff; text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; transition: border-color 150ms ease, box-shadow 150ms ease, color 150ms ease, background 150ms ease; }
.animation-button:hover { border-color: #9db2f4; }
.animation-button.is-active { color: #315bc8; border-color: #7392ed; background: #edf2ff; box-shadow: 0 0 0 3px rgb(91 127 229 / 12%); }
.animation-button:focus-visible, .transport-button:focus-visible { outline: 3px solid rgb(91 127 229 / 30%); outline-offset: 2px; }
.transport { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
.transport-button { min-height: 42px; border: 1px solid #d6dbe4; border-radius: 11px; color: #343a45; background: #fff; font-weight: 750; cursor: pointer; }
.transport-button:hover { background: #f0f3f8; }
.transport-button.primary { color: #fff; border-color: #171a20; background: #171a20; }
.transport-button.primary:hover { background: #2a2e36; }
.api-example { margin: 0; padding: 14px; overflow-x: auto; color: #cfdbed; border-radius: 12px; background: #171c24; font: 11px/1.65 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; }
.api-note { margin: 10px 0 0; color: #7d8694; font-size: 11px; line-height: 1.5; }
@media (max-width: 820px) {
.playground { width: min(100% - 20px, 620px); padding: 18px 0; }
.workspace { grid-template-columns: 1fr; }
.preview-card { min-height: 52vh; }
#avatar { width: min(72vw, 430px); }
}
</style>
</head>
<body>
<main class="playground">
<header class="topbar">
<div class="identity">
<span class="brand-mark" aria-hidden="true"></span>
<div>
<p class="eyebrow">${copy.eyebrow}</p>
<h1>${escapedHtml(payload.avatar.name)}</h1>
</div>
</div>
<div class="status" id="playback-status" data-state="playing">
<span class="status-dot" aria-hidden="true"></span>
<span id="playback-status-label">${copy.playing}</span>
</div>
</header>
<div class="workspace">
<section class="preview-card" aria-label="${copy.previewAriaLabel}">
<div id="avatar"></div>
</section>
<aside class="control-card">
<section class="section">
<div class="section-heading">
<div>
<h2>${copy.animations}</h2>
<p class="section-copy">${copy.animationsHelp}</p>
</div>
<span id="active-animation"></span>
</div>
<div class="animation-list" id="animation-list"></div>
</section>
<section class="section">
<div class="section-heading">
<div>
<h2>${copy.playback}</h2>
<p class="section-copy">${copy.playbackHelp}</p>
</div>
</div>
<div class="transport">
<button class="transport-button primary" id="play-animation" type="button">▶ ${copy.play}</button>
<button class="transport-button" id="pause-animation" type="button">Ⅱ ${copy.pause}</button>
<button class="transport-button" id="stop-animation" type="button">■ ${copy.stop}</button>
</div>
</section>
<section class="section">
<div class="section-heading">
<div>
<h2>${copy.api}</h2>
<p class="section-copy">${copy.apiHelp}</p>
</div>
</div>
<pre class="api-example"><code>import { createAvatar } from &#39;./avatar.js&#39;
const avatar = createAvatar('#avatar', {
animation: ${JSON.stringify(firstAnimation)},
size: 320,
})
avatar.play(${JSON.stringify(firstAnimation)})
avatar.pause()
avatar.stop()</code></pre>
<p class="api-note">${copy.apiNote}</p>
</section>
</aside>
</div>
</main>
<script type="module">
${generateJavaScriptAvatarModule(payload)}
let activeAnimation = ${JSON.stringify(firstAnimation)}
const animationList = document.querySelector('#animation-list')
const activeLabel = document.querySelector('#active-animation')
const status = document.querySelector('#playback-status')
const statusLabel = document.querySelector('#playback-status-label')
const animationButtons = new Map()
const setStatus = state => {
status.dataset.state = state
statusLabel.textContent = state === 'playing' ? ${JSON.stringify(copy.playing)} : state === 'paused' ? ${JSON.stringify(copy.paused)} : ${JSON.stringify(copy.stopped)}
}
const avatar = createAvatar('#avatar', {
size: '100%',
autoplay: false,
onAnimationEnd: () => setStatus('stopped'),
})
const selectAnimation = name => {
activeAnimation = name
activeLabel.textContent = DATA.animations[name]?.name || name
animationButtons.forEach((button, animationName) => {
button.classList.toggle('is-active', animationName === name)
button.setAttribute('aria-pressed', String(animationName === name))
})
}
availableAnimations.forEach(name => {
const button = document.createElement('button')
button.type = 'button'
button.className = 'animation-button'
button.textContent = DATA.animations[name]?.name || name
button.title = name
button.addEventListener('click', () => {
selectAnimation(name)
avatar.play(name)
setStatus('playing')
})
animationButtons.set(name, button)
animationList.append(button)
})
document.querySelector('#play-animation').addEventListener('click', () => {
avatar.play(activeAnimation)
setStatus('playing')
})
document.querySelector('#pause-animation').addEventListener('click', () => {
avatar.pause()
setStatus('paused')
})
document.querySelector('#stop-animation').addEventListener('click', () => {
avatar.stop()
setStatus('stopped')
})
selectAnimation(activeAnimation)
avatar.play(activeAnimation)
window.avatar = avatar
</script>
</body>
</html>
`
}
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<AnimationName extends string = string> = Readonly<{
version: number
avatar: Readonly<{ name: string } & Record<string, unknown>>
expressions: Readonly<Record<string, unknown>>
animations: Readonly<Record<AnimationName, unknown>>
}>
export type RuntimeAvatar<AnimationName extends string = string> = {
play: (animation?: AnimationName) => RuntimeAvatar<AnimationName>
pause: () => RuntimeAvatar<AnimationName>
stop: () => RuntimeAvatar<AnimationName>
destroy: () => void
}
export type AvatarRuntimeModule<AnimationName extends string = string> = {
createAvatar: (
target: HTMLElement,
options: {
animation: AnimationName
autoplay: boolean
loop?: boolean
size: string
onAnimationEnd: (animation: AnimationName) => void
}
) => RuntimeAvatar<AnimationName>
}
export const AVATAR_RUNTIME_VERSION = 1
const ENGINE_SOURCE = ${JSON.stringify(standaloneEngineSource)}
const BROWSER_RUNTIME_SOURCE = ${JSON.stringify(proceduralBrowserRuntime)}
const runtimes = new WeakMap<object, Promise<unknown>>()
const serializeData = (data: AvatarData) =>
JSON.stringify(data)
.replace(/</g, '\\\\u003c')
.replace(/\\u2028/g, '\\\\u2028')
.replace(/\\u2029/g, '\\\\u2029')
export const loadAvatarRuntime = <AnimationName extends string>(
data: AvatarData<AnimationName>
): Promise<AvatarRuntimeModule<AnimationName>> => {
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<AvatarRuntimeModule<AnimationName>>
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<AnimationName>
})
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<AvatarHandle, AvatarProps>(function ${componentName}(
{
animation = ${JSON.stringify(animationNames[0])},
playing = true,
loop,
size = 240,
className,
style,
onAnimationEnd,
},
ref
) {
const host = useRef<HTMLSpanElement>(null)
const controller = useRef<RuntimeAvatar<AnimationName> | 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<AnimationName> | null = null
void loadAvatarRuntime<AnimationName>(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 <span ref={host} className={className} style={{ display: 'inline-block', width: dimension, height: dimension, ...style }} />
})
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}`
}

60
src/expressionEditing.ts Normal file
View File

@ -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 } : {}) }
}

1251
src/geometry.ts Normal file

File diff suppressed because it is too large Load Diff

514
src/i18n.ts Normal file
View File

@ -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<string, string> = {
'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<string, string> = {
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<StudioLanguageContextValue | null>(null)
export function StudioLanguageProvider({ children }: { children: ReactNode }) {
const [language, setLanguage] = useState<StudioLanguage>(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
}

377
src/i18n.zh.ts Normal file
View File

@ -0,0 +1,377 @@
export const chinese: Record<string, string> = {
'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
}

4
src/lib/utils.ts Normal file
View File

@ -0,0 +1,4 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs))

11
src/main.tsx Normal file
View File

@ -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(
<StrictMode>
<App />
</StrictMode>
)

View File

@ -0,0 +1,35 @@
export type ManipulationSession<Value> = {
initial: Value
latest: Value
}
export const beginManipulation = <Value>(initial: Value): ManipulationSession<Value> => ({
initial,
latest: initial,
})
export const beginManipulationFromRenderedValue = <Value>(
readRenderedValue: () => Value
): ManipulationSession<Value> => beginManipulation(readRenderedValue())
export const previewManipulation = <Value>(
session: ManipulationSession<Value>,
next: Value,
preview?: (value: Value) => void
) => {
session.latest = next
preview?.(next)
}
export const finishManipulation = <Value>(
session: ManipulationSession<Value>,
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
}

68
src/playback.ts Normal file
View File

@ -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()

219
src/presets.ts Normal file
View File

@ -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<string, number[]> = {
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<string, string> = {
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.',
}

View File

@ -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;
}
`

View File

@ -0,0 +1,43 @@
import { motionValue, type MotionValue } from 'motion'
import { poseFromExpression, rotationRing, type Expression, type Point3 } from './geometry'
export type RenderedRotationGizmo = {
xPath: MotionValue<string>
yPath: MotionValue<string>
zPath: MotionValue<string>
}
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)
}

82
src/renderedScene.ts Normal file
View File

@ -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<string>
backPaths: MotionValue<string>[]
frontPaths: MotionValue<string>[]
backNodeIds: { current: (string | null)[] }
frontNodeIds: { current: (string | null)[] }
leftPath: MotionValue<string>
rightPath: MotionValue<string>
leftOpacity: MotionValue<number>
rightOpacity: MotionValue<number>
offsetX: MotionValue<number>
offsetY: MotionValue<number>
wirePaths: MotionValue<string>[]
}
export type RenderedColors = {
body: MotionValue<string>
eyes: MotionValue<string>
}
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
}

288
src/sequences.ts Normal file
View File

@ -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<string>(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<SequenceStep> | 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<AvatarSequence> | 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<BlinkSettings> | 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<string>()
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<string, number>()
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),
}
}

84
src/snapshotExporter.ts Normal file
View File

@ -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<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
}
return entities[character]
})
const path = (value: string, fill: string, opacity = 1) =>
value ? `<path d="${escapeXml(value)}" fill="${fill}" opacity="${opacity}"/>` : ''
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 `<rect x="-150" y="-150" width="300" height="300" fill="${fill}"/>`
}
const gradientMarkup = (options: SnapshotOptions) => {
if (options.background === 'linear') {
return `<linearGradient id="snapshot-linear" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${options.colorFrom}"/><stop offset="1" stop-color="${options.colorTo}"/></linearGradient>`
}
if (options.background === 'radial') {
return `<radialGradient id="snapshot-radial" cx="50%" cy="42%" r="70%"><stop offset="0" stop-color="${options.colorFrom}"/><stop offset="1" stop-color="${options.colorTo}"/></radialGradient>`
}
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),
`<g clip-path="url(#snapshot-head-clip)">${path(scene.leftPath.get(), colors.eyes, scene.leftOpacity.get())}${path(scene.rightPath.get(), colors.eyes, scene.rightOpacity.get())}</g>`,
...frontPaths.map(value => path(value, colors.body)),
].join('')
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-150 -150 300 300" width="${options.size}" height="${options.size}" role="img" aria-label="${escapeXml(name)}">
<defs>${gradientMarkup(options)}<clipPath id="snapshot-head-clip"><path d="${escapeXml(headPath)}"/></clipPath></defs>
${backgroundMarkup(options)}
<g transform="translate(${offsetX} ${offsetY})">${body}</g>
</svg>`
}
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}`
}

View File

@ -0,0 +1,2 @@
export { expressionFields, poseFromExpression, renderAvatar } from './geometry'
export { ambientBodyOffset, applyAmbientMotion, hasAmbientMotion } from './ambientMotion'

File diff suppressed because one or more lines are too long

61
src/storedZip.ts Normal file
View File

@ -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' })
}

140
src/studioDocument.ts Normal file
View File

@ -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<Omit<StudioDocument, 'version'>>
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<StatePlaybackSelection> | 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<StudioDocument> | 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<StudioDocument> | 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<Storage, 'getItem'> = 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
},
}
}

2177
src/styles.css Normal file

File diff suppressed because it is too large Load Diff

665
src/surfaces.ts Normal file
View File

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

24
tsconfig.json Normal file
View File

@ -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"]
}

23
vite.config.ts Normal file
View File

@ -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,
},
})

9
vitest.config.ts Normal file
View File

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
globals: true,
include: ['src/__tests__/**/*-test.ts'],
},
})

1992
yarn.lock Normal file

File diff suppressed because it is too large Load Diff