feat: import standalone Avatar Lab
This commit is contained in:
parent
8913438acd
commit
e547bc9f7b
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
dist/
|
||||
node_modules/
|
||||
.yarn/
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
*.local
|
||||
.DS_Store
|
||||
coverage/
|
||||
6
.prettierignore
Normal file
6
.prettierignore
Normal file
@ -0,0 +1,6 @@
|
||||
dist/
|
||||
node_modules/
|
||||
legacy/
|
||||
src/defaultStudioDocument.json
|
||||
src/standaloneEngine.generated.ts
|
||||
yarn.lock
|
||||
10
.prettierrc
Normal file
10
.prettierrc
Normal 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
1
.yarnrc.yml
Normal file
@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
34
AGENTS.md
Normal file
34
AGENTS.md
Normal 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
68
CONTEXT.md
Normal 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
51
README.md
Normal 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
22
components.json
Normal 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": {}
|
||||
}
|
||||
26
docs/adr/0022-model-avatar-studio-as-a-versioned-document.md
Normal file
26
docs/adr/0022-model-avatar-studio-as-a-versioned-document.md
Normal 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.
|
||||
26
docs/adr/0023-name-avatar-sequences-animations.md
Normal file
26
docs/adr/0023-name-avatar-sequences-animations.md
Normal 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
13
index.html
Normal 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
4015
legacy/index.html
Normal file
File diff suppressed because it is too large
Load Diff
2894
legacy/spherical-eyes.html
Normal file
2894
legacy/spherical-eyes.html
Normal file
File diff suppressed because it is too large
Load Diff
50
package.json
Normal file
50
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
33
scripts/generate-standalone-engine.mjs
Normal file
33
scripts/generate-standalone-engine.mjs
Normal 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
6015
src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
46
src/__tests__/ambient-motion-test.ts
Normal file
46
src/__tests__/ambient-motion-test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
59
src/__tests__/avatars-test.ts
Normal file
59
src/__tests__/avatars-test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
37
src/__tests__/canvas-preview-test.ts
Normal file
37
src/__tests__/canvas-preview-test.ts
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
||||
154
src/__tests__/exporter-test.ts
Normal file
154
src/__tests__/exporter-test.ts
Normal 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('</title>')
|
||||
expect(source).toContain('\\u003c/script>')
|
||||
})
|
||||
|
||||
it('packages the direct-open demo and reusable module together', async () => {
|
||||
const archive = new Uint8Array(await generateJavaScriptAvatarPackage(payload).arrayBuffer())
|
||||
const contents = new TextDecoder().decode(archive)
|
||||
|
||||
expect(contents).toContain('index.html')
|
||||
expect(contents).toContain('avatar.js')
|
||||
})
|
||||
|
||||
it('generates a typed React component backed by the local runtime', () => {
|
||||
const source = generateReactAvatarComponent(payload)
|
||||
|
||||
expect(source).toContain("from 'react'")
|
||||
expect(source).toContain("from './avatar-runtime'")
|
||||
expect(source).toContain("from './strobi.avatar'")
|
||||
expect(source).toContain('export type { AnimationName }')
|
||||
expect(source).toContain('animation?: AnimationName')
|
||||
expect(source).toContain('forwardRef<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')
|
||||
})
|
||||
})
|
||||
24
src/__tests__/expression-editing-test.ts
Normal file
24
src/__tests__/expression-editing-test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
91
src/__tests__/geometry-editor-test.ts
Normal file
91
src/__tests__/geometry-editor-test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
45
src/__tests__/i18n-test.ts
Normal file
45
src/__tests__/i18n-test.ts
Normal 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.'
|
||||
)
|
||||
})
|
||||
})
|
||||
47
src/__tests__/manipulation-session-test.ts
Normal file
47
src/__tests__/manipulation-session-test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
43
src/__tests__/playback-test.ts
Normal file
43
src/__tests__/playback-test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
26
src/__tests__/presets-test.ts
Normal file
26
src/__tests__/presets-test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
20
src/__tests__/rendered-rotation-gizmo-test.ts
Normal file
20
src/__tests__/rendered-rotation-gizmo-test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
72
src/__tests__/rendered-scene-test.ts
Normal file
72
src/__tests__/rendered-scene-test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
97
src/__tests__/sequences-test.ts
Normal file
97
src/__tests__/sequences-test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
41
src/__tests__/snapshot-exporter-test.ts
Normal file
41
src/__tests__/snapshot-exporter-test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
171
src/__tests__/studio-document-test.ts
Normal file
171
src/__tests__/studio-document-test.ts
Normal 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
96
src/ambientMotion.ts
Normal 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
211
src/avatars.ts
Normal 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
117
src/body.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import { surfaceLabels, surfacePresets, type SurfaceConfig, type SurfaceType } from './surfaces'
|
||||
|
||||
export type BodyVector = readonly [number, number, number]
|
||||
|
||||
export type BodyNode = {
|
||||
id: string
|
||||
name: string
|
||||
surface: SurfaceConfig
|
||||
position: BodyVector
|
||||
rotation: BodyVector
|
||||
}
|
||||
|
||||
export type AvatarBody = {
|
||||
primary: SurfaceConfig
|
||||
nodes: BodyNode[]
|
||||
}
|
||||
|
||||
export const bodyPrimitiveTypes = [
|
||||
'sphere',
|
||||
'cube',
|
||||
'capsule',
|
||||
'cylinder',
|
||||
'cone',
|
||||
'diamond',
|
||||
] as const
|
||||
|
||||
export const MAX_BODY_NODES = 16
|
||||
|
||||
const allSurfaceTypes = Object.keys(surfacePresets) as SurfaceType[]
|
||||
const finite = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value)
|
||||
const vector = (value: unknown): value is BodyVector =>
|
||||
Array.isArray(value) && value.length === 3 && value.every(finite)
|
||||
|
||||
export const parseSurfaceConfig = (value: unknown, fallback: SurfaceConfig): SurfaceConfig => {
|
||||
if (!value || typeof value !== 'object') return { ...fallback }
|
||||
const candidate = value as Partial<SurfaceConfig>
|
||||
const type =
|
||||
candidate.type && allSurfaceTypes.includes(candidate.type) ? candidate.type : fallback.type
|
||||
const preset = surfacePresets[type]
|
||||
const numericFields = ['width', 'height', 'depth', 'roundness'] as const
|
||||
if (numericFields.some(field => !finite(candidate[field]))) return { ...fallback }
|
||||
if (candidate.morphRoundness !== undefined && !finite(candidate.morphRoundness))
|
||||
return { ...fallback }
|
||||
if (candidate.tipRoundness !== undefined && !finite(candidate.tipRoundness))
|
||||
return { ...fallback }
|
||||
if (candidate.baseRoundness !== undefined && !finite(candidate.baseRoundness))
|
||||
return { ...fallback }
|
||||
return { ...preset, ...candidate, type }
|
||||
}
|
||||
|
||||
export const parseAvatarBody = (value: unknown, fallbackPrimary: SurfaceConfig): AvatarBody => {
|
||||
if (!value || typeof value !== 'object') return { primary: fallbackPrimary, nodes: [] }
|
||||
const candidate = value as Partial<AvatarBody>
|
||||
const primary = parseSurfaceConfig(candidate.primary, fallbackPrimary)
|
||||
const seenIds = new Set<string>()
|
||||
const nodes = Array.isArray(candidate.nodes)
|
||||
? candidate.nodes
|
||||
.filter((node): node is BodyNode => {
|
||||
if (!node || typeof node !== 'object') return false
|
||||
const surface = (node as BodyNode).surface
|
||||
const id = (node as BodyNode).id
|
||||
if (id === 'primary' || seenIds.has(id)) return false
|
||||
const valid = Boolean(
|
||||
typeof (node as BodyNode).id === 'string' &&
|
||||
id &&
|
||||
typeof (node as BodyNode).name === 'string' &&
|
||||
surface &&
|
||||
bodyPrimitiveTypes.includes(surface.type as (typeof bodyPrimitiveTypes)[number]) &&
|
||||
finite(surface.width) &&
|
||||
finite(surface.height) &&
|
||||
finite(surface.depth) &&
|
||||
finite(surface.roundness) &&
|
||||
vector((node as BodyNode).position) &&
|
||||
vector((node as BodyNode).rotation)
|
||||
)
|
||||
if (valid) seenIds.add(id)
|
||||
return valid
|
||||
})
|
||||
.slice(0, MAX_BODY_NODES)
|
||||
.map(node => ({
|
||||
...node,
|
||||
surface: parseSurfaceConfig(node.surface, surfacePresets[node.surface.type]),
|
||||
}))
|
||||
: []
|
||||
return { primary, nodes }
|
||||
}
|
||||
|
||||
export const createBodyNode = (
|
||||
type: (typeof bodyPrimitiveTypes)[number],
|
||||
index: number
|
||||
): BodyNode => {
|
||||
const preset = surfacePresets[type]
|
||||
const scale = 0.34
|
||||
const side = index % 2 === 0 ? -1 : 1
|
||||
return {
|
||||
id: `shape-${crypto.randomUUID()}`,
|
||||
name: `${surfaceLabels[type]} ${index + 1}`,
|
||||
surface: {
|
||||
...preset,
|
||||
width: preset.width * scale,
|
||||
height: preset.height * scale,
|
||||
depth: preset.depth * scale,
|
||||
},
|
||||
position: [side * 82, -72, -18],
|
||||
rotation: [0, 0, 0],
|
||||
}
|
||||
}
|
||||
|
||||
export const duplicateBodyNode = (source: BodyNode): BodyNode => ({
|
||||
...source,
|
||||
id: `shape-${crypto.randomUUID()}`,
|
||||
name: `${source.name} copie`,
|
||||
surface: { ...source.surface },
|
||||
position: [source.position[0] + 14, source.position[1] + 14, source.position[2]],
|
||||
rotation: [...source.rotation],
|
||||
})
|
||||
12
src/canvasPreview.ts
Normal file
12
src/canvasPreview.ts
Normal 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
|
||||
57
src/components/ui/accordion.tsx
Normal file
57
src/components/ui/accordion.tsx
Normal 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 }
|
||||
71
src/components/ui/alert-dialog.tsx
Normal file
71
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
35
src/components/ui/badge.tsx
Normal file
35
src/components/ui/badge.tsx
Normal 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 }
|
||||
44
src/components/ui/button.tsx
Normal file
44
src/components/ui/button.tsx
Normal 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 }
|
||||
56
src/components/ui/card.tsx
Normal file
56
src/components/ui/card.tsx
Normal 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 }
|
||||
62
src/components/ui/context-menu.tsx
Normal file
62
src/components/ui/context-menu.tsx
Normal 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,
|
||||
}
|
||||
63
src/components/ui/field.tsx
Normal file
63
src/components/ui/field.tsx
Normal 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 }
|
||||
20
src/components/ui/input.tsx
Normal file
20
src/components/ui/input.tsx
Normal 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 }
|
||||
70
src/components/ui/select.tsx
Normal file
70
src/components/ui/select.tsx
Normal 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 }
|
||||
24
src/components/ui/separator.tsx
Normal file
24
src/components/ui/separator.tsx
Normal 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 }
|
||||
21
src/components/ui/switch.tsx
Normal file
21
src/components/ui/switch.tsx
Normal 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 }
|
||||
42
src/components/ui/tabs.tsx
Normal file
42
src/components/ui/tabs.tsx
Normal 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 }
|
||||
41
src/components/ui/tooltip.tsx
Normal file
41
src/components/ui/tooltip.tsx
Normal 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 }
|
||||
2146
src/defaultStudioDocument.json
Normal file
2146
src/defaultStudioDocument.json
Normal file
File diff suppressed because it is too large
Load Diff
547
src/exporter.ts
Normal file
547
src/exporter.ts
Normal 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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}
|
||||
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 './avatar.js'
|
||||
|
||||
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
60
src/expressionEditing.ts
Normal 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
1251
src/geometry.ts
Normal file
File diff suppressed because it is too large
Load Diff
514
src/i18n.ts
Normal file
514
src/i18n.ts
Normal 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
377
src/i18n.zh.ts
Normal 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
4
src/lib/utils.ts
Normal 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
11
src/main.tsx
Normal 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>
|
||||
)
|
||||
35
src/manipulationSession.ts
Normal file
35
src/manipulationSession.ts
Normal 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
68
src/playback.ts
Normal 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
219
src/presets.ts
Normal 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.',
|
||||
}
|
||||
351
src/proceduralBrowserRuntime.ts
Normal file
351
src/proceduralBrowserRuntime.ts
Normal 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;
|
||||
}
|
||||
`
|
||||
43
src/renderedRotationGizmo.ts
Normal file
43
src/renderedRotationGizmo.ts
Normal 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
82
src/renderedScene.ts
Normal 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
288
src/sequences.ts
Normal 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
84
src/snapshotExporter.ts
Normal 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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
}
|
||||
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}`
|
||||
}
|
||||
2
src/standaloneEngine.entry.ts
Normal file
2
src/standaloneEngine.entry.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { expressionFields, poseFromExpression, renderAvatar } from './geometry'
|
||||
export { ambientBodyOffset, applyAmbientMotion, hasAmbientMotion } from './ambientMotion'
|
||||
2
src/standaloneEngine.generated.ts
Normal file
2
src/standaloneEngine.generated.ts
Normal file
File diff suppressed because one or more lines are too long
61
src/storedZip.ts
Normal file
61
src/storedZip.ts
Normal 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
140
src/studioDocument.ts
Normal 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
2177
src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
665
src/surfaces.ts
Normal file
665
src/surfaces.ts
Normal file
@ -0,0 +1,665 @@
|
||||
import type { Point3 } from './geometry'
|
||||
|
||||
export type SurfaceType =
|
||||
'sphere' | 'mickey' | 'cursor' | 'cube' | 'capsule' | 'cylinder' | 'cone' | 'diamond'
|
||||
|
||||
export type SurfaceConfig = {
|
||||
type: SurfaceType
|
||||
width: number
|
||||
height: number
|
||||
depth: number
|
||||
roundness: number
|
||||
morphRoundness?: number
|
||||
tipRoundness?: number
|
||||
baseRoundness?: number
|
||||
}
|
||||
|
||||
export type SurfaceSample = {
|
||||
point: Point3
|
||||
normal: Point3
|
||||
}
|
||||
|
||||
export const surfacePresets: Record<SurfaceType, SurfaceConfig> = {
|
||||
sphere: { type: 'sphere', width: 240, height: 240, depth: 240, roundness: 1 },
|
||||
mickey: { type: 'mickey', width: 220, height: 210, depth: 145, roundness: 1 },
|
||||
cursor: { type: 'cursor', width: 175, height: 260, depth: 145, roundness: 0 },
|
||||
cube: { type: 'cube', width: 245, height: 245, depth: 220, roundness: 0 },
|
||||
capsule: { type: 'capsule', width: 205, height: 270, depth: 205, roundness: 1 },
|
||||
cylinder: {
|
||||
type: 'cylinder',
|
||||
width: 235,
|
||||
height: 250,
|
||||
depth: 215,
|
||||
roundness: 0.45,
|
||||
morphRoundness: 0,
|
||||
},
|
||||
cone: {
|
||||
type: 'cone',
|
||||
width: 250,
|
||||
height: 265,
|
||||
depth: 225,
|
||||
roundness: 0,
|
||||
morphRoundness: 0,
|
||||
tipRoundness: 0.55,
|
||||
baseRoundness: 0.45,
|
||||
},
|
||||
diamond: { type: 'diamond', width: 235, height: 260, depth: 215, roundness: 0 },
|
||||
}
|
||||
|
||||
export const surfaceLabels: Record<SurfaceType, string> = {
|
||||
sphere: 'Sphère',
|
||||
mickey: 'Mickey',
|
||||
cursor: 'Curseur',
|
||||
cube: 'Cube',
|
||||
capsule: 'Capsule',
|
||||
cylinder: 'Cylindre',
|
||||
cone: 'Cône',
|
||||
diamond: 'Diamant',
|
||||
}
|
||||
|
||||
const signedPower = (value: number, exponent: number) =>
|
||||
Math.sign(value) * Math.abs(value) ** exponent
|
||||
|
||||
const superellipsoid = (
|
||||
longitude: number,
|
||||
latitude: number,
|
||||
width: number,
|
||||
height: number,
|
||||
depth: number,
|
||||
verticalExponent: number,
|
||||
horizontalExponent: number
|
||||
): Point3 => {
|
||||
const latitudeCosine = signedPower(Math.cos(latitude), verticalExponent)
|
||||
return [
|
||||
(width / 2) * latitudeCosine * signedPower(Math.sin(longitude), horizontalExponent),
|
||||
(height / 2) * signedPower(Math.sin(latitude), verticalExponent),
|
||||
(depth / 2) * latitudeCosine * signedPower(Math.cos(longitude), horizontalExponent),
|
||||
]
|
||||
}
|
||||
|
||||
const capsule = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => {
|
||||
const radiusX = config.width / 2
|
||||
const radiusZ = config.depth / 2
|
||||
const capRadius = Math.min(radiusX, config.height / 2)
|
||||
const straightHalf = Math.max(0, (config.height - capRadius * 2) / 2)
|
||||
const meridianLength = straightHalf * 2 + Math.PI * capRadius
|
||||
const distance = ((latitude + Math.PI / 2) / Math.PI) * meridianLength
|
||||
let radial = radiusX
|
||||
let y = 0
|
||||
|
||||
if (distance < (Math.PI * capRadius) / 2) {
|
||||
const angle = -Math.PI / 2 + distance / capRadius
|
||||
radial = radiusX * Math.cos(angle)
|
||||
y = -straightHalf + capRadius * Math.sin(angle)
|
||||
} else if (distance <= (Math.PI * capRadius) / 2 + straightHalf * 2) {
|
||||
y = -straightHalf + distance - (Math.PI * capRadius) / 2
|
||||
} else {
|
||||
const angle = (distance - (Math.PI * capRadius) / 2 - straightHalf * 2) / capRadius
|
||||
radial = radiusX * Math.cos(angle)
|
||||
y = straightHalf + capRadius * Math.sin(angle)
|
||||
}
|
||||
|
||||
const depthScale = radiusX ? radiusZ / radiusX : 1
|
||||
return [radial * Math.sin(longitude), y, radial * depthScale * Math.cos(longitude)]
|
||||
}
|
||||
|
||||
const clampRoundness = (roundness: number | undefined) => Math.max(0, Math.min(2, roundness ?? 0))
|
||||
|
||||
const diamondExponent = (config: SurfaceConfig) => 1 + clampRoundness(config.roundness) / 2
|
||||
|
||||
const MIN_CUBE_SURFACE_POWER = 0.04
|
||||
|
||||
const cubeExponent = (config: SurfaceConfig) => {
|
||||
if (config.roundness <= 0) return Infinity
|
||||
// The implicit superellipsoid power moves from an almost-flat cube to an ellipsoid.
|
||||
const surfacePower =
|
||||
MIN_CUBE_SURFACE_POWER + (clampRoundness(config.roundness) / 2) * (1 - MIN_CUBE_SURFACE_POWER)
|
||||
return 2 / surfacePower
|
||||
}
|
||||
|
||||
const lpSurface = (
|
||||
config: SurfaceConfig,
|
||||
longitude: number,
|
||||
latitude: number,
|
||||
exponent: number
|
||||
): Point3 => {
|
||||
const sphereX = Math.cos(latitude) * Math.sin(longitude)
|
||||
const sphereY = Math.sin(latitude)
|
||||
const sphereZ = Math.cos(latitude) * Math.cos(longitude)
|
||||
const length = Number.isFinite(exponent)
|
||||
? (Math.abs(sphereX) ** exponent +
|
||||
Math.abs(sphereY) ** exponent +
|
||||
Math.abs(sphereZ) ** exponent) **
|
||||
(1 / exponent) || 1
|
||||
: Math.max(Math.abs(sphereX), Math.abs(sphereY), Math.abs(sphereZ)) || 1
|
||||
return [
|
||||
(config.width / 2) * (sphereX / length),
|
||||
(config.height / 2) * (sphereY / length),
|
||||
(config.depth / 2) * (sphereZ / length),
|
||||
]
|
||||
}
|
||||
|
||||
const diamond = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => {
|
||||
return lpSurface(config, longitude, latitude, diamondExponent(config))
|
||||
}
|
||||
|
||||
const cube = (config: SurfaceConfig, longitude: number, latitude: number): Point3 =>
|
||||
lpSurface(config, longitude, latitude, cubeExponent(config))
|
||||
|
||||
const MAX_CONE_TIP_FRACTION = 0.24
|
||||
const MAX_CONE_BASE_FRACTION = 0.2
|
||||
const MAX_CYLINDER_EDGE_FRACTION = 0.22
|
||||
|
||||
type RadialProfile = {
|
||||
radiusScale: number
|
||||
verticalProgress: number
|
||||
}
|
||||
|
||||
const morphProgress = (config: SurfaceConfig) => clampRoundness(config.morphRoundness) / 2
|
||||
|
||||
const morphProfileToEllipsoid = (
|
||||
config: SurfaceConfig,
|
||||
progress: number,
|
||||
profile: RadialProfile
|
||||
): RadialProfile => {
|
||||
const amount = morphProgress(config)
|
||||
const clampedProgress = Math.max(0, Math.min(1, progress))
|
||||
const ellipsoidRadius = Math.sin(clampedProgress * Math.PI)
|
||||
const ellipsoidVerticalProgress = (1 - Math.cos(clampedProgress * Math.PI)) / 2
|
||||
return {
|
||||
radiusScale: profile.radiusScale + (ellipsoidRadius - profile.radiusScale) * amount,
|
||||
verticalProgress:
|
||||
profile.verticalProgress + (ellipsoidVerticalProgress - profile.verticalProgress) * amount,
|
||||
}
|
||||
}
|
||||
|
||||
const cubic = (
|
||||
start: number,
|
||||
firstControl: number,
|
||||
secondControl: number,
|
||||
end: number,
|
||||
progress: number
|
||||
) => {
|
||||
const inverse = 1 - progress
|
||||
return (
|
||||
inverse ** 3 * start +
|
||||
3 * inverse * inverse * progress * firstControl +
|
||||
3 * inverse * progress * progress * secondControl +
|
||||
progress ** 3 * end
|
||||
)
|
||||
}
|
||||
|
||||
const coneRounding = (config: SurfaceConfig) => ({
|
||||
tipFraction: (config.tipRoundness ?? 0) * MAX_CONE_TIP_FRACTION,
|
||||
baseFraction: (config.baseRoundness ?? 0) * MAX_CONE_BASE_FRACTION,
|
||||
})
|
||||
|
||||
/** Cylinder half-profile with a quarter-round transition at both caps. */
|
||||
const cylinderProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => {
|
||||
const clampedProgress = Math.max(0, Math.min(1, progress))
|
||||
const edgeFraction = config.roundness * MAX_CYLINDER_EDGE_FRACTION
|
||||
if (edgeFraction <= 0) {
|
||||
return {
|
||||
radiusScale: 1,
|
||||
verticalProgress: (Math.sin((clampedProgress - 0.5) * Math.PI) + 1) / 2,
|
||||
}
|
||||
}
|
||||
|
||||
if (clampedProgress < edgeFraction) {
|
||||
const angle = -Math.PI / 2 + (clampedProgress / edgeFraction) * (Math.PI / 2)
|
||||
return {
|
||||
radiusScale: 1 - edgeFraction + edgeFraction * Math.cos(angle),
|
||||
verticalProgress: (edgeFraction + edgeFraction * Math.sin(angle)) / 2,
|
||||
}
|
||||
}
|
||||
|
||||
if (clampedProgress > 1 - edgeFraction) {
|
||||
const angle = ((clampedProgress - (1 - edgeFraction)) / edgeFraction) * (Math.PI / 2)
|
||||
return {
|
||||
radiusScale: 1 - edgeFraction + edgeFraction * Math.cos(angle),
|
||||
verticalProgress: 1 - edgeFraction / 2 + (edgeFraction * Math.sin(angle)) / 2,
|
||||
}
|
||||
}
|
||||
|
||||
const middleProgress = (clampedProgress - edgeFraction) / (1 - edgeFraction * 2)
|
||||
return {
|
||||
radiusScale: 1,
|
||||
verticalProgress: edgeFraction / 2 + middleProgress * (1 - edgeFraction),
|
||||
}
|
||||
}
|
||||
|
||||
const morphedCylinderProfileAt = (config: SurfaceConfig, progress: number) =>
|
||||
morphProfileToEllipsoid(config, progress, cylinderProfileAt(config, progress))
|
||||
|
||||
const radiusScaleAtVerticalProgress = (
|
||||
config: SurfaceConfig,
|
||||
verticalProgress: number,
|
||||
profileAt: (config: SurfaceConfig, progress: number) => RadialProfile
|
||||
) => {
|
||||
const progress = Math.max(0, Math.min(1, verticalProgress))
|
||||
let lower = 0
|
||||
let upper = 1
|
||||
for (let iteration = 0; iteration < 14; iteration += 1) {
|
||||
const candidate = (lower + upper) / 2
|
||||
if (profileAt(config, candidate).verticalProgress < progress) lower = candidate
|
||||
else upper = candidate
|
||||
}
|
||||
return profileAt(config, (lower + upper) / 2).radiusScale
|
||||
}
|
||||
|
||||
/** Rounded half-profile revolved around the cone's vertical axis. */
|
||||
const coneProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => {
|
||||
const clampedProgress = Math.max(0, Math.min(1, progress))
|
||||
const { tipFraction, baseFraction } = coneRounding(config)
|
||||
|
||||
if (baseFraction > 0 && clampedProgress < baseFraction) {
|
||||
const curveProgress = clampedProgress / baseFraction
|
||||
return {
|
||||
radiusScale: cubic(
|
||||
1 - baseFraction,
|
||||
1,
|
||||
1 - baseFraction / 2,
|
||||
1 - baseFraction,
|
||||
curveProgress
|
||||
),
|
||||
verticalProgress: cubic(0, 0, baseFraction / 2, baseFraction, curveProgress),
|
||||
}
|
||||
}
|
||||
|
||||
if (tipFraction > 0 && clampedProgress > 1 - tipFraction) {
|
||||
const curveProgress = (clampedProgress - (1 - tipFraction)) / tipFraction
|
||||
return {
|
||||
radiusScale: cubic(tipFraction, tipFraction / 2, tipFraction / 4, 0, curveProgress),
|
||||
verticalProgress: cubic(1 - tipFraction, 1 - tipFraction / 2, 1, 1, curveProgress),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
radiusScale: 1 - clampedProgress,
|
||||
verticalProgress: clampedProgress,
|
||||
}
|
||||
}
|
||||
|
||||
const morphedConeProfileAt = (config: SurfaceConfig, progress: number) =>
|
||||
morphProfileToEllipsoid(config, progress, coneProfileAt(config, progress))
|
||||
|
||||
export const cursorLayout = (config: SurfaceConfig) => {
|
||||
const coneHeight = config.height * 0.36
|
||||
const bodyHeight = config.height - coneHeight
|
||||
return {
|
||||
coneApexY: -config.height / 2,
|
||||
coneBaseY: -config.height / 2 + coneHeight,
|
||||
bodyHeight,
|
||||
bodyCenterY: config.height / 2 - bodyHeight / 2,
|
||||
bodyWidth: config.width * 0.54,
|
||||
bodyDepth: config.depth * 0.62,
|
||||
}
|
||||
}
|
||||
|
||||
export const surfacePointAt = (
|
||||
config: SurfaceConfig,
|
||||
longitude: number,
|
||||
latitude: number
|
||||
): Point3 => {
|
||||
const { width, height, depth } = config
|
||||
switch (config.type) {
|
||||
case 'sphere':
|
||||
case 'mickey':
|
||||
return superellipsoid(longitude, latitude, width, height, depth, 1, 1)
|
||||
case 'cube':
|
||||
return cube(config, longitude, latitude)
|
||||
case 'cylinder': {
|
||||
const progress = (latitude + Math.PI / 2) / Math.PI
|
||||
const profile = morphedCylinderProfileAt(config, progress)
|
||||
return [
|
||||
(width / 2) * profile.radiusScale * Math.sin(longitude),
|
||||
-height / 2 + height * profile.verticalProgress,
|
||||
(depth / 2) * profile.radiusScale * Math.cos(longitude),
|
||||
]
|
||||
}
|
||||
case 'cursor': {
|
||||
const layout = cursorLayout(config)
|
||||
const progress = (latitude + Math.PI / 2) / Math.PI
|
||||
const bodyConfig = {
|
||||
...config,
|
||||
width: layout.bodyWidth,
|
||||
height: layout.bodyHeight,
|
||||
depth: layout.bodyDepth,
|
||||
}
|
||||
const profile = cylinderProfileAt(bodyConfig, progress)
|
||||
return [
|
||||
(layout.bodyWidth / 2) * profile.radiusScale * Math.sin(longitude),
|
||||
layout.bodyCenterY - layout.bodyHeight / 2 + layout.bodyHeight * profile.verticalProgress,
|
||||
(layout.bodyDepth / 2) * profile.radiusScale * Math.cos(longitude),
|
||||
]
|
||||
}
|
||||
case 'diamond':
|
||||
return diamond(config, longitude, latitude)
|
||||
case 'capsule':
|
||||
return capsule(config, longitude, latitude)
|
||||
case 'cone': {
|
||||
const progress = (latitude + Math.PI / 2) / Math.PI
|
||||
const profile = morphedConeProfileAt(config, progress)
|
||||
return [
|
||||
(width / 2) * profile.radiusScale * Math.sin(longitude),
|
||||
height / 2 - height * profile.verticalProgress,
|
||||
(depth / 2) * profile.radiusScale * Math.cos(longitude),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const subtract = (left: Point3, right: Point3): Point3 => [
|
||||
left[0] - right[0],
|
||||
left[1] - right[1],
|
||||
left[2] - right[2],
|
||||
]
|
||||
|
||||
const normalize = ([x, y, z]: Point3): Point3 => {
|
||||
const length = Math.hypot(x, y, z) || 1
|
||||
return [x / length, y / length, z / length]
|
||||
}
|
||||
|
||||
const normalFromTangents = (
|
||||
config: SurfaceConfig,
|
||||
longitudeTangent: Point3,
|
||||
latitudeTangent: Point3
|
||||
) => {
|
||||
const orientation = config.type === 'cone' ? -1 : 1
|
||||
return normalize([
|
||||
orientation *
|
||||
(longitudeTangent[1] * latitudeTangent[2] - longitudeTangent[2] * latitudeTangent[1]),
|
||||
orientation *
|
||||
(longitudeTangent[2] * latitudeTangent[0] - longitudeTangent[0] * latitudeTangent[2]),
|
||||
orientation *
|
||||
(longitudeTangent[0] * latitudeTangent[1] - longitudeTangent[1] * latitudeTangent[0]),
|
||||
])
|
||||
}
|
||||
|
||||
const tangentNormalAt = (config: SurfaceConfig, longitude: number, latitude: number) => {
|
||||
const epsilon = 0.0005
|
||||
if (config.type === 'cone' && latitude >= Math.PI / 2 - epsilon) return [0, -1, 0] as Point3
|
||||
const longitudeBefore = surfacePointAt(config, longitude - epsilon, latitude)
|
||||
const longitudeAfter = surfacePointAt(config, longitude + epsilon, latitude)
|
||||
const latitudeBefore = surfacePointAt(
|
||||
config,
|
||||
longitude,
|
||||
Math.max(-Math.PI / 2, latitude - epsilon)
|
||||
)
|
||||
const latitudeAfter = surfacePointAt(config, longitude, Math.min(Math.PI / 2, latitude + epsilon))
|
||||
return normalFromTangents(
|
||||
config,
|
||||
subtract(longitudeAfter, longitudeBefore),
|
||||
subtract(latitudeAfter, latitudeBefore)
|
||||
)
|
||||
}
|
||||
|
||||
const signedMagnitude = (value: number, exponent: number) =>
|
||||
Math.sign(value) * Math.abs(value) ** exponent
|
||||
|
||||
const lpNormal = (config: SurfaceConfig, point: Point3, exponent: number): Point3 => {
|
||||
const radiusX = config.width / 2 || 1
|
||||
const radiusY = config.height / 2 || 1
|
||||
const radiusZ = config.depth / 2 || 1
|
||||
return normalize([
|
||||
signedMagnitude(point[0] / radiusX, exponent - 1) / radiusX,
|
||||
signedMagnitude(point[1] / radiusY, exponent - 1) / radiusY,
|
||||
signedMagnitude(point[2] / radiusZ, exponent - 1) / radiusZ,
|
||||
])
|
||||
}
|
||||
|
||||
const diamondNormal = (config: SurfaceConfig, point: Point3): Point3 =>
|
||||
lpNormal(config, point, diamondExponent(config))
|
||||
|
||||
const cubeNormal = (config: SurfaceConfig, point: Point3): Point3 => {
|
||||
const exponent = cubeExponent(config)
|
||||
if (Number.isFinite(exponent)) return lpNormal(config, point, exponent)
|
||||
|
||||
const normalized = [
|
||||
point[0] / (config.width / 2 || 1),
|
||||
point[1] / (config.height / 2 || 1),
|
||||
point[2] / (config.depth / 2 || 1),
|
||||
] as Point3
|
||||
const dominantAxis = normalized.reduce(
|
||||
(largest, value, index) => (Math.abs(value) > Math.abs(normalized[largest]) ? index : largest),
|
||||
0
|
||||
)
|
||||
const normal: Point3 = [
|
||||
dominantAxis === 0 ? Math.sign(normalized[0]) : 0,
|
||||
dominantAxis === 1 ? Math.sign(normalized[1]) : 0,
|
||||
dominantAxis === 2 ? Math.sign(normalized[2]) : 0,
|
||||
]
|
||||
return normal
|
||||
}
|
||||
|
||||
const lpFrontSample = (
|
||||
config: SurfaceConfig,
|
||||
x: number,
|
||||
y: number,
|
||||
exponent: number,
|
||||
normalAt: (config: SurfaceConfig, point: Point3) => Point3
|
||||
): SurfaceSample => {
|
||||
const radiusX = config.width / 2 || 1
|
||||
const radiusY = config.height / 2 || 1
|
||||
const radiusZ = config.depth / 2 || 1
|
||||
if (!Number.isFinite(exponent)) {
|
||||
const point: Point3 = [
|
||||
Math.max(-radiusX, Math.min(radiusX, x)),
|
||||
Math.max(-radiusY, Math.min(radiusY, y)),
|
||||
radiusZ,
|
||||
]
|
||||
return { point, normal: normalAt(config, point) }
|
||||
}
|
||||
|
||||
const normalizedY = Math.max(-1, Math.min(1, y / radiusY))
|
||||
const availableX = Math.max(0, 1 - Math.abs(normalizedY) ** exponent) ** (1 / exponent)
|
||||
const surfaceX = Math.max(-radiusX * availableX, Math.min(radiusX * availableX, x))
|
||||
const normalizedX = surfaceX / radiusX
|
||||
const normalizedZ =
|
||||
Math.max(0, 1 - Math.abs(normalizedX) ** exponent - Math.abs(normalizedY) ** exponent) **
|
||||
(1 / exponent)
|
||||
const point: Point3 = [surfaceX, normalizedY * radiusY, radiusZ * normalizedZ]
|
||||
return { point, normal: normalAt(config, point) }
|
||||
}
|
||||
|
||||
const ellipsoidFrontSample = (
|
||||
x: number,
|
||||
y: number,
|
||||
radiusX: number,
|
||||
radiusY: number,
|
||||
radiusZ: number,
|
||||
centerY = 0
|
||||
): SurfaceSample => {
|
||||
const localY = y - centerY
|
||||
const remaining = Math.max(0, 1 - (x / (radiusX || 1)) ** 2 - (localY / (radiusY || 1)) ** 2)
|
||||
const z = radiusZ * Math.sqrt(remaining)
|
||||
return {
|
||||
point: [x, y, z],
|
||||
normal: normalize([
|
||||
x / (radiusX * radiusX || 1),
|
||||
localY / (radiusY * radiusY || 1),
|
||||
z / (radiusZ * radiusZ || 1),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
const radialProfileFrontSample = (
|
||||
config: SurfaceConfig,
|
||||
x: number,
|
||||
y: number,
|
||||
profileAt: (config: SurfaceConfig, progress: number) => RadialProfile,
|
||||
verticalDirection: -1 | 1
|
||||
): SurfaceSample => {
|
||||
const radiusX = config.width / 2 || 1
|
||||
const radiusZ = config.depth / 2 || 1
|
||||
const verticalProgress = Math.max(0, Math.min(1, 0.5 + verticalDirection * (y / config.height)))
|
||||
const radialScale = radiusScaleAtVerticalProgress(config, verticalProgress, profileAt)
|
||||
const sectionRadiusX = radiusX * radialScale
|
||||
const sectionRadiusZ = radiusZ * radialScale
|
||||
const surfaceX = Math.max(-sectionRadiusX, Math.min(sectionRadiusX, x))
|
||||
const remaining = sectionRadiusX > 0 ? Math.max(0, 1 - (surfaceX / sectionRadiusX) ** 2) : 0
|
||||
const z = sectionRadiusZ * Math.sqrt(remaining)
|
||||
const derivativeStep = 0.0001
|
||||
const previousProgress = Math.max(0, verticalProgress - derivativeStep)
|
||||
const nextProgress = Math.min(1, verticalProgress + derivativeStep)
|
||||
const previousScale = radiusScaleAtVerticalProgress(config, previousProgress, profileAt)
|
||||
const nextScale = radiusScaleAtVerticalProgress(config, nextProgress, profileAt)
|
||||
const scaleDerivative = (nextScale - previousScale) / (nextProgress - previousProgress || 1)
|
||||
const radialRemainder = Math.max(Math.sqrt(remaining), 0.0001)
|
||||
const depthRatio = radiusZ / radiusX
|
||||
const depthXDerivative = (-depthRatio * surfaceX) / (sectionRadiusX * radialRemainder || 1)
|
||||
const depthYDerivative =
|
||||
(verticalDirection * radiusZ * scaleDerivative) / (config.height * radialRemainder || 1)
|
||||
return {
|
||||
point: [surfaceX, y, z],
|
||||
normal: normalize([-depthXDerivative, -depthYDerivative, 1]),
|
||||
}
|
||||
}
|
||||
|
||||
/** Project canonical face coordinates onto a primitive's front-facing sheet. */
|
||||
export const surfaceFrontSampleAt = (
|
||||
config: SurfaceConfig,
|
||||
x: number,
|
||||
y: number
|
||||
): SurfaceSample => {
|
||||
const radiusX = config.width / 2 || 1
|
||||
const radiusY = config.height / 2 || 1
|
||||
const radiusZ = config.depth / 2 || 1
|
||||
|
||||
switch (config.type) {
|
||||
case 'sphere':
|
||||
case 'mickey':
|
||||
return ellipsoidFrontSample(x, y, radiusX, radiusY, radiusZ)
|
||||
|
||||
case 'cube':
|
||||
return lpFrontSample(config, x, y, cubeExponent(config), cubeNormal)
|
||||
|
||||
case 'capsule': {
|
||||
const capRadiusY = Math.min(radiusX, radiusY)
|
||||
const straightHalf = Math.max(0, radiusY - capRadiusY)
|
||||
const capCenterY = y < -straightHalf ? -straightHalf : y > straightHalf ? straightHalf : y
|
||||
return ellipsoidFrontSample(x, y, radiusX, capRadiusY, radiusZ, capCenterY)
|
||||
}
|
||||
|
||||
case 'cylinder':
|
||||
return radialProfileFrontSample(config, x, y, morphedCylinderProfileAt, 1)
|
||||
|
||||
case 'cursor': {
|
||||
const layout = cursorLayout(config)
|
||||
const bodyConfig = {
|
||||
...config,
|
||||
width: layout.bodyWidth,
|
||||
height: layout.bodyHeight,
|
||||
depth: layout.bodyDepth,
|
||||
}
|
||||
const sample = radialProfileFrontSample(
|
||||
bodyConfig,
|
||||
x,
|
||||
y - layout.bodyCenterY,
|
||||
cylinderProfileAt,
|
||||
1
|
||||
)
|
||||
return {
|
||||
point: [sample.point[0], sample.point[1] + layout.bodyCenterY, sample.point[2]],
|
||||
normal: sample.normal,
|
||||
}
|
||||
}
|
||||
|
||||
case 'cone':
|
||||
return radialProfileFrontSample(config, x, y, morphedConeProfileAt, -1)
|
||||
|
||||
case 'diamond':
|
||||
return lpFrontSample(config, x, y, diamondExponent(config), diamondNormal)
|
||||
}
|
||||
}
|
||||
|
||||
export const surfaceNormalAt = (
|
||||
config: SurfaceConfig,
|
||||
longitude: number,
|
||||
latitude: number
|
||||
): Point3 => {
|
||||
const point = surfacePointAt(config, longitude, latitude)
|
||||
|
||||
// An ellipsoid has a cheap exact normal. This is also the overwhelmingly
|
||||
// common path for the default spherical head.
|
||||
if (config.type === 'sphere' || config.type === 'mickey') {
|
||||
const halfWidth = config.width / 2 || 1
|
||||
const halfHeight = config.height / 2 || 1
|
||||
const halfDepth = config.depth / 2 || 1
|
||||
return normalize([
|
||||
point[0] / (halfWidth * halfWidth),
|
||||
point[1] / (halfHeight * halfHeight),
|
||||
point[2] / (halfDepth * halfDepth),
|
||||
])
|
||||
}
|
||||
|
||||
if (config.type === 'cylinder' && config.roundness <= 0 && (config.morphRoundness ?? 0) <= 0) {
|
||||
return normalize([
|
||||
Math.sin(longitude) / (config.width / 2 || 1),
|
||||
0,
|
||||
Math.cos(longitude) / (config.depth / 2 || 1),
|
||||
])
|
||||
}
|
||||
|
||||
if (config.type === 'diamond') {
|
||||
return diamondNormal(config, point)
|
||||
}
|
||||
|
||||
if (config.type === 'cube') {
|
||||
return cubeNormal(config, point)
|
||||
}
|
||||
|
||||
return tangentNormalAt(config, longitude, latitude)
|
||||
}
|
||||
|
||||
export const surfaceSampleAt = (
|
||||
config: SurfaceConfig,
|
||||
longitude: number,
|
||||
latitude: number
|
||||
): SurfaceSample => {
|
||||
const point = surfacePointAt(config, longitude, latitude)
|
||||
|
||||
if (config.type === 'sphere' || config.type === 'mickey') {
|
||||
const halfWidth = config.width / 2 || 1
|
||||
const halfHeight = config.height / 2 || 1
|
||||
const halfDepth = config.depth / 2 || 1
|
||||
return {
|
||||
point,
|
||||
normal: normalize([
|
||||
point[0] / (halfWidth * halfWidth),
|
||||
point[1] / (halfHeight * halfHeight),
|
||||
point[2] / (halfDepth * halfDepth),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
if (config.type === 'cylinder' && config.roundness <= 0 && (config.morphRoundness ?? 0) <= 0) {
|
||||
return {
|
||||
point,
|
||||
normal: normalize([
|
||||
Math.sin(longitude) / (config.width / 2 || 1),
|
||||
0,
|
||||
Math.cos(longitude) / (config.depth / 2 || 1),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
if (config.type === 'diamond') {
|
||||
return {
|
||||
point,
|
||||
normal: diamondNormal(config, point),
|
||||
}
|
||||
}
|
||||
|
||||
if (config.type === 'cube') {
|
||||
return {
|
||||
point,
|
||||
normal: cubeNormal(config, point),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
point,
|
||||
normal: tangentNormalAt(config, longitude, latitude),
|
||||
}
|
||||
}
|
||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal 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
23
vite.config.ts
Normal 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
9
vitest.config.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
include: ['src/__tests__/**/*-test.ts'],
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user