feat(studio): prepare app for GitHub release
Add distribution metadata, Strobi app icons, CI, and comprehensive documentation. Migrate the project to pnpm and reorganize the Studio into domain-focused modules and components.
This commit is contained in:
parent
fe00d640fb
commit
3b2e134169
33
.github/workflows/ci.yml
vendored
Normal file
33
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['**']
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
name: Validate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '22.12.0'
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
run: npm install --global pnpm@11.21.0
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Validate project
|
||||||
|
run: pnpm check
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -1,11 +1,6 @@
|
|||||||
dist/
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
.yarn/
|
pnpm-debug.log*
|
||||||
!.yarn/patches
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/sdks
|
|
||||||
!.yarn/versions
|
|
||||||
*.local
|
*.local
|
||||||
.DS_Store
|
.DS_Store
|
||||||
coverage/
|
coverage/
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
dist/
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
legacy/
|
legacy/
|
||||||
src/defaultStudioDocument.json
|
public/favicon.svg
|
||||||
src/standaloneEngine.generated.ts
|
public/robots.txt
|
||||||
yarn.lock
|
src/features/studio/defaultStudioDocument.json
|
||||||
|
src/features/export/standaloneEngine.generated.ts
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
nodeLinker: node-modules
|
|
||||||
16
AGENTS.md
16
AGENTS.md
@ -8,11 +8,11 @@ Read `CONTEXT.md` before changing behavior or persistence semantics.
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn dev
|
pnpm dev
|
||||||
yarn typecheck
|
pnpm typecheck
|
||||||
yarn test
|
pnpm test
|
||||||
yarn build
|
pnpm build
|
||||||
yarn check
|
pnpm check
|
||||||
```
|
```
|
||||||
|
|
||||||
## Engineering rules
|
## Engineering rules
|
||||||
@ -22,9 +22,9 @@ yarn check
|
|||||||
- Preserve the split between durable React state and high-frequency Motion values.
|
- Preserve the split between durable React state and high-frequency Motion values.
|
||||||
- Do not use `useMemo`, `useCallback` or `memo`; React Compiler handles memoization.
|
- Do not use `useMemo`, `useCallback` or `memo`; React Compiler handles memoization.
|
||||||
- Add or update focused tests for domain behavior.
|
- Add or update focused tests for domain behavior.
|
||||||
- Run `yarn typecheck` and the relevant test file while working, then `yarn check` before committing.
|
- Run `pnpm typecheck` and the relevant test file while working, then `pnpm check` before committing.
|
||||||
- `src/standaloneEngine.generated.ts` is generated. Update it with `yarn engine`, never by hand.
|
- `src/features/export/standaloneEngine.generated.ts` is generated. Update it with `pnpm engine`, never by hand.
|
||||||
- Keep English, French and Simplified Chinese copy synchronized across `src/i18n.ts` and `src/i18n.zh.ts`.
|
- Keep English, French and Simplified Chinese copy synchronized across `src/i18n/index.ts` and `src/i18n/zh.ts`.
|
||||||
- Preserve project JSON compatibility only with the current pre-release schema unless a migration is explicitly requested.
|
- Preserve project JSON compatibility only with the current pre-release schema unless a migration is explicitly requested.
|
||||||
|
|
||||||
## UI conventions
|
## UI conventions
|
||||||
|
|||||||
@ -56,14 +56,16 @@ manipulation pauses it.
|
|||||||
- The primary body shape carries the facial coordinate system and eyes.
|
- The primary body shape carries the facial coordinate system and eyes.
|
||||||
- Secondary primitives have independent local dimensions, position and rotation.
|
- Secondary primitives have independent local dimensions, position and rotation.
|
||||||
- Expressions remain compatible across body surfaces because they operate in the common facial frame.
|
- 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.
|
- `features/avatar/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.
|
- `features/studio/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.
|
- Live editing may update the preview, but unsaved avatar/expression edits must remain reversible.
|
||||||
|
|
||||||
## Architecture boundaries
|
## Architecture boundaries
|
||||||
|
|
||||||
- Pure geometry and document logic live in framework-independent TypeScript modules.
|
- Pure geometry and document logic live in framework-independent TypeScript modules.
|
||||||
- `App.tsx` coordinates UI and application state; avoid moving domain calculations into components.
|
- `features/studio/useStudioController.ts` coordinates durable UI state and application operations.
|
||||||
|
- `features/studio/components/StudioView.tsx` composes the stage, inspector and dialogs.
|
||||||
|
- Keep domain calculations out of React components and controller hooks.
|
||||||
- Motion values own frame-by-frame visual updates; React state owns durable editor state.
|
- 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.
|
- 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.
|
- The generated standalone engine is committed and checked for freshness in local validation.
|
||||||
|
|||||||
173
README.md
173
README.md
@ -1,51 +1,160 @@
|
|||||||
# Bible Strong Avatar Lab
|
# Bible Strong Avatar Lab
|
||||||
|
|
||||||
Standalone React/Vite studio for creating procedural 2D avatars, expressions and animations.
|
Bible Strong Avatar Lab is a browser-based authoring studio for procedural 2D avatars. It combines 3D-inspired geometry with SVG rendering so you can construct a character, define its neutral appearance, create expressions, compose reusable animations, and export the result without depending on the Studio UI.
|
||||||
The project was extracted from Bible Strong so it can evolve independently.
|
|
||||||
|
|
||||||
## Start
|
The application runs entirely in the browser. Projects are stored locally and can be moved between browsers with JSON export/import; no account or backend is required.
|
||||||
|
|
||||||
Requirements: Node.js 22.12+ and Corepack/Yarn 4.
|
## What you can do
|
||||||
|
|
||||||
|
- Create, rename, duplicate, reorder, and delete avatars.
|
||||||
|
- Build a body from a primary facial surface and additional 3D-inspired primitives.
|
||||||
|
- Adjust dimensions, roundness, position, local rotation, perspective, colors, and wireframe guides.
|
||||||
|
- Edit each eye independently or link their size, proportions, and position.
|
||||||
|
- Manipulate the avatar directly on the canvas with translation and rotation controls.
|
||||||
|
- Save, duplicate, reorder, and delete reusable expressions.
|
||||||
|
- Add temporary body and eye color overrides to an expression.
|
||||||
|
- Add subtle ambient movement to the body and eyes.
|
||||||
|
- Build animations from expression steps with custom hold times and transitions.
|
||||||
|
- Choose loop, play-once, or ping-pong playback and configure automatic blinking.
|
||||||
|
- Preview, play, pause, and stop animations inside the Studio.
|
||||||
|
- Take SVG or PNG snapshots with transparent, solid, linear-gradient, or radial-gradient backgrounds.
|
||||||
|
- Export a standalone React package or a framework-free JavaScript/HTML package.
|
||||||
|
- Export and import the complete Studio project as JSON.
|
||||||
|
- Use the interface in English, French, or Simplified Chinese.
|
||||||
|
|
||||||
|
## How the Studio works
|
||||||
|
|
||||||
|
The workspace has a live canvas on the left and an inspector on the right. Start by selecting an avatar, then move through the four main areas:
|
||||||
|
|
||||||
|
1. **Pose** — inspect a temporary pose or open the avatar editor to change the character's durable body, colors, and neutral eyes.
|
||||||
|
2. **Expressions** — create named visual presets. Expression eye values are relative to the avatar's neutral appearance, so the same behavior remains compatible with different body surfaces.
|
||||||
|
3. **Animations** — arrange expressions on a timeline, tune step duration and transition style, choose a playback mode, and configure blinking.
|
||||||
|
4. **Export** — choose animations to include, download an integration package, configure Photo Mode, or back up the full Studio project.
|
||||||
|
|
||||||
|
Changes to the Studio document are saved automatically in browser local storage. Unsaved edits inside an avatar, expression, or animation editor can still be cancelled. Use **Export → Studio project** to create a portable JSON backup before clearing browser data or moving to another device.
|
||||||
|
|
||||||
|
## Avatars and behavior libraries
|
||||||
|
|
||||||
|
An avatar owns its body geometry, colors, and neutral eye appearance. Expressions and animations initially come from the bundled base behavior library.
|
||||||
|
|
||||||
|
When an avatar's expressions or animations are edited for the first time, the Studio copies both collections into an avatar-specific behavior library. From that point on, behavior changes affect only that avatar. Duplicating an avatar also duplicates its custom behavior, and transferring an animation includes every expression it references.
|
||||||
|
|
||||||
|
This copy-on-write model lets multiple avatars share the defaults without accidental cross-avatar edits while keeping customized characters fully independent.
|
||||||
|
|
||||||
|
## Export formats
|
||||||
|
|
||||||
|
### React package
|
||||||
|
|
||||||
|
The React export is a local ZIP package containing a reusable TypeScript/React avatar component and the selected animations. It is intended for integration into React applications without shipping the Avatar Lab interface.
|
||||||
|
|
||||||
|
### JavaScript package
|
||||||
|
|
||||||
|
The JavaScript export is a self-contained ZIP project with an ES module, the selected avatar data and animations, and an HTML demo. It can be used without React.
|
||||||
|
|
||||||
|
### Photo Mode
|
||||||
|
|
||||||
|
Photo Mode exports the currently rendered avatar as SVG or PNG. You can choose the resolution and use a transparent, solid, linear-gradient, or radial-gradient background.
|
||||||
|
|
||||||
|
### Studio project
|
||||||
|
|
||||||
|
The JSON project file contains the complete current document: avatars, base and avatar-specific behavior libraries, expressions, animations, and playback selection. Importing a project replaces the current local document after confirmation.
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- Node.js 22.12 or newer
|
||||||
|
- pnpm 11.21 (declared by the repository's `packageManager` field)
|
||||||
|
|
||||||
|
### Install and run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
corepack enable
|
pnpm install
|
||||||
yarn install
|
pnpm dev
|
||||||
yarn dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:5173/`.
|
Open [http://localhost:5173](http://localhost:5173).
|
||||||
|
|
||||||
|
## Available commands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
| ------------------- | --------------------------------------------------------------------------------- |
|
||||||
|
| `pnpm dev` | Regenerate the standalone engine and start Vite in development mode. |
|
||||||
|
| `pnpm typecheck` | Run TypeScript in strict, no-emit mode. |
|
||||||
|
| `pnpm test` | Run the Vitest suite once. |
|
||||||
|
| `pnpm test:watch` | Run Vitest in watch mode. |
|
||||||
|
| `pnpm engine` | Regenerate the standalone engine used by exported packages. |
|
||||||
|
| `pnpm engine:check` | Verify that the committed generated engine is current. |
|
||||||
|
| `pnpm build` | Regenerate the engine and create a production build in `dist/`. |
|
||||||
|
| `pnpm preview` | Serve the production build locally. |
|
||||||
|
| `pnpm format` | Format the repository with Prettier. |
|
||||||
|
| `pnpm check` | Run engine freshness, formatting, type checking, tests, and the production build. |
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
|
Before opening a pull request, run the complete local check:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn typecheck
|
pnpm check
|
||||||
yarn test
|
|
||||||
yarn build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`yarn check` runs the generated-engine freshness check and the full validation suite.
|
The GitHub Actions workflow runs the same command on every push and pull request.
|
||||||
|
|
||||||
## Project map
|
## Production build and deployment
|
||||||
|
|
||||||
- `src/App.tsx`: Studio interface and orchestration.
|
Create the static production bundle:
|
||||||
- `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
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
- An avatar owns its body and neutral eye appearance.
|
The deployable site is written to `dist/`. Serve it locally before publishing:
|
||||||
- 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.
|
```bash
|
||||||
|
pnpm preview
|
||||||
|
```
|
||||||
|
|
||||||
|
The application is a client-only Vite site with no server runtime or environment variables. The build uses relative asset paths, so the contents of `dist/` can be hosted at a domain root or a subpath, including a GitHub Pages project URL. Any static host that serves `index.html` can deploy it.
|
||||||
|
|
||||||
|
## Technical overview
|
||||||
|
|
||||||
|
- **React 19** coordinates the editor UI and durable application state.
|
||||||
|
- **TypeScript** is configured in strict mode.
|
||||||
|
- **Vite 8** provides the development server and production build.
|
||||||
|
- **Motion** owns high-frequency rendering and playback values.
|
||||||
|
- **SVG** renders the procedural avatar geometry.
|
||||||
|
- **Tailwind CSS 4** and reusable components under `src/components/ui/` provide the interface layer.
|
||||||
|
- **Vitest** covers geometry, playback, editing, persistence, rendering, and export behavior.
|
||||||
|
|
||||||
|
Geometry, playback, document operations, and the standalone runtime remain framework-independent. React state stores durable editor data; Motion values handle frame-by-frame visual updates without forcing React renders.
|
||||||
|
|
||||||
|
## Repository map
|
||||||
|
|
||||||
|
| Path | Responsibility |
|
||||||
|
| ---------------------------------------- | ------------------------------------------------------------------------ |
|
||||||
|
| `src/app/` | Application shell, shared controls, utilities, and global styles. |
|
||||||
|
| `src/components/ui/` | Reusable interface primitives. |
|
||||||
|
| `src/features/avatar/` | Avatar identity, body, geometry, expressions, manipulation, and tests. |
|
||||||
|
| `src/features/animation/` | Animation sequences, framework-independent playback, and tests. |
|
||||||
|
| `src/features/rendering/` | Stable SVG scene, canvas preview, rotation gizmo, and rendering tests. |
|
||||||
|
| `src/features/export/` | Packages, snapshots, ZIPs, standalone runtime, and export tests. |
|
||||||
|
| `src/features/studio/` | Studio controller, composed views, persistence, bundled data, and tests. |
|
||||||
|
| `src/i18n/` | Localized interface copy and translation tests. |
|
||||||
|
| `src/lib/` | Small shared utilities without product-domain ownership. |
|
||||||
|
| `scripts/generate-standalone-engine.mjs` | Standalone-engine generator. |
|
||||||
|
| `docs/adr/` | Accepted architecture decisions. |
|
||||||
|
| `legacy/` | Self-contained HTML prototypes that preceded the React application. |
|
||||||
|
|
||||||
|
## Persistence and privacy
|
||||||
|
|
||||||
|
There is no remote backend in this repository. The complete Studio document and interface language preference are stored in the browser's local storage. Exported files are generated locally in the browser. Clearing site data removes the local project, so JSON export is the recommended backup mechanism.
|
||||||
|
|
||||||
|
The current project format is a pre-release schema. Compatibility is maintained with the current schema only unless an explicit migration is introduced.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Keep domain calculations outside React components, preserve the separation between durable React state and high-frequency Motion values, and add focused tests for domain changes. Do not edit `src/features/export/standaloneEngine.generated.ts` directly; run `pnpm engine` after changing its source modules.
|
||||||
|
|
||||||
|
English, French, and Simplified Chinese interface copy must stay synchronized across `src/i18n/index.ts` and `src/i18n/zh.ts`.
|
||||||
|
|
||||||
|
For the domain vocabulary, invariants, and architecture boundaries, read [CONTEXT.md](./CONTEXT.md).
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "",
|
"config": "",
|
||||||
"css": "src/styles.css",
|
"css": "src/app/styles.css",
|
||||||
"baseColor": "neutral",
|
"baseColor": "neutral",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
|
|||||||
27
index.html
27
index.html
@ -1,10 +1,33 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="fr">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="light" />
|
<meta name="color-scheme" content="light" />
|
||||||
<title>Bible Strong Avatar Lab</title>
|
<meta name="theme-color" content="#171a1e" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Create procedural 2D avatars, expressions and animations in your browser, then export reusable React, JavaScript, SVG and PNG assets."
|
||||||
|
/>
|
||||||
|
<meta name="application-name" content="Bible Strong Avatar Lab" />
|
||||||
|
<meta name="robots" content="index, follow" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:locale" content="en_US" />
|
||||||
|
<meta property="og:title" content="Bible Strong Avatar Lab — Procedural 2D Avatar Studio" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="Design procedural 2D avatars, expressions and reusable animations directly in your browser."
|
||||||
|
/>
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
|
<meta name="twitter:title" content="Bible Strong Avatar Lab" />
|
||||||
|
<meta
|
||||||
|
name="twitter:description"
|
||||||
|
content="Design procedural 2D avatars, expressions and reusable animations directly in your browser."
|
||||||
|
/>
|
||||||
|
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="./site.webmanifest" />
|
||||||
|
<title>Bible Strong Avatar Lab — Procedural 2D Avatar Studio</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "bible-strong-avatar-lab",
|
"name": "bible-strong-avatar-lab",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"description": "A browser-based studio for designing procedural 2D avatars, expressions and reusable animations.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "yarn@4.12.0",
|
"packageManager": "pnpm@11.21.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.12.0"
|
"node": ">=22.12.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "yarn engine && vite",
|
"dev": "pnpm engine && vite",
|
||||||
"build": "yarn engine && vite build",
|
"build": "pnpm engine && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"engine": "node scripts/generate-standalone-engine.mjs",
|
"engine": "node scripts/generate-standalone-engine.mjs",
|
||||||
"engine:check": "node scripts/generate-standalone-engine.mjs --check",
|
"engine:check": "node scripts/generate-standalone-engine.mjs --check",
|
||||||
@ -18,7 +19,7 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"check": "yarn engine:check && yarn format:check && yarn typecheck && yarn test && yarn build"
|
"check": "pnpm engine:check && pnpm format:check && pnpm typecheck && pnpm test && pnpm build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.7.0",
|
"@base-ui/react": "^1.7.0",
|
||||||
|
|||||||
1695
pnpm-lock.yaml
generated
Normal file
1695
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
13
public/favicon.svg
Normal file
13
public/favicon.svg
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-140 -140 280 280" role="img" aria-label="Strobi">
|
||||||
|
<title>Strobi</title>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="strobi-head">
|
||||||
|
<path d="M106.3-60.51A122.32 122.31-29.65 0 1-106.3 60.51 122.32 122.31-29.65 0 1 106.3-60.51Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<path fill="#5b7fe5" d="M106.3-60.51A122.32 122.31-29.65 0 1-106.3 60.51 122.32 122.31-29.65 0 1 106.3-60.51Z"/>
|
||||||
|
<g clip-path="url(#strobi-head)" fill="#111316">
|
||||||
|
<path d="M15.21-72.27l1.35-.23 1.39-.09 1.41.06 1.42.19 1.4.33 1.38.47 1.34.59 1.28.72 1.21.83 1.11.94 1.01 1.03.89 1.12.76 1.18.62 1.24.61 1.43.61 1.44.61 1.46.59 1.47.6 1.48.59 1.49.58 1.5.58 1.52.57 1.52.57 1.54.56 1.55.56 1.56.55 1.56.54 1.58.4 1.42.24 1.45.07 1.45-.09 1.44-.27 1.4-.43 1.36-.59 1.29-.74 1.21-.88 1.11-1.02.99-1.14.87-1.24.73-1.33.59-1.4.42-1.46.27-1.49.11-1.51-.06-1.51-.23-1.49-.39-1.44-.55-1.39-.7-1.3-.84-1.21-.97-1.1-1.09-.98-1.2-.83-1.28-.69-1.36-.53-1.41-.51-1.6-.5-1.6-.51-1.59-.52-1.57-.51-1.57-.51-1.56-.52-1.55-.52-1.54-.51-1.52-.52-1.52-.52-1.5-.52-1.49-.52-1.48-.52-1.47-.38-1.31-.22-1.32-.06-1.32.1-1.29.26-1.26.41-1.21.56-1.13.7-1.06.83-.96.94-.86 1.06-.75 1.15-.62 1.23-.5Z"/>
|
||||||
|
<path d="M64.87-80.08h.96l1.02.13 1.07.25 1.1.38 1.12.49 1.13.61 1.13.71 1.11.81 1.08.9 1.03.97.97 1.04.9 1.1.83 1.14.72 1.17.76 1.33.75 1.34.73 1.35.72 1.37.71 1.38.7 1.39.69 1.41.67 1.41.66 1.43.65 1.44.63 1.45.62 1.46.61 1.47.59 1.47.47 1.33.35 1.32.24 1.31.12 1.27v1.22l-.13 1.16-.25 1.08-.37.99-.48.88-.59.76-.7.64-.79.5-.87.36-.94.21-1.01.06-1.06-.1-1.09-.25-1.11-.39-1.12-.54-1.12-.69-1.09-.81-1.05-.93-1-1.04-.94-1.13-.86-1.21-.77-1.28-.68-1.32-.57-1.35-.58-1.52-.59-1.52-.6-1.5-.62-1.49-.62-1.48-.64-1.47-.64-1.46-.66-1.45-.66-1.43-.68-1.43-.68-1.41-.69-1.39-.71-1.39-.71-1.37-.58-1.21-.46-1.2-.34-1.17-.21-1.13-.08-1.07.04-1.01.17-.92.3-.83.4-.72.53-.62.62-.5.73-.38.81-.25Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/icon-192.png
Normal file
BIN
public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
public/icon-512.png
Normal file
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
31
public/site.webmanifest
Normal file
31
public/site.webmanifest
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "Bible Strong Avatar Lab",
|
||||||
|
"short_name": "Avatar Lab",
|
||||||
|
"description": "A browser-based studio for designing procedural 2D avatars, expressions and reusable animations.",
|
||||||
|
"lang": "en",
|
||||||
|
"start_url": ".",
|
||||||
|
"scope": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#171a1e",
|
||||||
|
"theme_color": "#171a1e",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -12,7 +12,7 @@ const result = await build({
|
|||||||
write: false,
|
write: false,
|
||||||
minify: true,
|
minify: true,
|
||||||
lib: {
|
lib: {
|
||||||
entry: path.join(root, 'src/standaloneEngine.entry.ts'),
|
entry: path.join(root, 'src/features/export/standaloneEngine.entry.ts'),
|
||||||
formats: ['iife'],
|
formats: ['iife'],
|
||||||
name: 'AvatarProceduralEngine',
|
name: 'AvatarProceduralEngine',
|
||||||
},
|
},
|
||||||
@ -22,12 +22,12 @@ const outputs = Array.isArray(result) ? result.flatMap(item => item.output) : re
|
|||||||
const chunk = outputs.find(item => item.type === 'chunk')
|
const chunk = outputs.find(item => item.type === 'chunk')
|
||||||
if (!chunk) throw new Error('Standalone avatar engine bundle was not generated.')
|
if (!chunk) throw new Error('Standalone avatar engine bundle was not generated.')
|
||||||
|
|
||||||
const outputPath = path.join(root, 'src/standaloneEngine.generated.ts')
|
const outputPath = path.join(root, 'src/features/export/standaloneEngine.generated.ts')
|
||||||
const source = `// Generated by scripts/generate-standalone-engine.mjs. Do not edit.\nexport const standaloneEngineSource = ${JSON.stringify(chunk.code)}\n`
|
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')) {
|
if (process.argv.includes('--check')) {
|
||||||
const current = await readFile(outputPath, 'utf8').catch(() => '')
|
const current = await readFile(outputPath, 'utf8').catch(() => '')
|
||||||
if (current !== source) throw new Error('Standalone avatar engine is stale. Run yarn engine.')
|
if (current !== source) throw new Error('Standalone avatar engine is stale. Run pnpm engine.')
|
||||||
} else {
|
} else {
|
||||||
await writeFile(outputPath, source)
|
await writeFile(outputPath, source)
|
||||||
}
|
}
|
||||||
|
|||||||
6015
src/App.tsx
6015
src/App.tsx
File diff suppressed because it is too large
Load Diff
16
src/app/App.tsx
Normal file
16
src/app/App.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { StudioView } from '@/features/studio/components/StudioView'
|
||||||
|
import { useStudioController } from '@/features/studio/useStudioController'
|
||||||
|
import { StudioLanguageProvider } from '@/i18n'
|
||||||
|
|
||||||
|
function StudioApp() {
|
||||||
|
const controller = useStudioController()
|
||||||
|
return <StudioView {...controller} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<StudioLanguageProvider>
|
||||||
|
<StudioApp />
|
||||||
|
</StudioLanguageProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
195
src/app/components/common.tsx
Normal file
195
src/app/components/common.tsx
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
import { Pause, Play, Square } from 'lucide-react'
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { useId } from 'react'
|
||||||
|
|
||||||
|
import { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { useStudioLanguage } from '@/i18n'
|
||||||
|
|
||||||
|
import { type PlaybackStatus } from '@/app/studio-utils'
|
||||||
|
import { type SnapshotBackground } from '@/features/export/snapshotExporter'
|
||||||
|
import { type RenderedColors, type RenderedScene } from '@/features/rendering/renderedScene'
|
||||||
|
export function ControlSection({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
compact = false,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
compact?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const { t } = useStudioLanguage()
|
||||||
|
return (
|
||||||
|
<section className={`control-section${compact ? ' control-section-compact' : ''}`}>
|
||||||
|
<header className="control-section-header">
|
||||||
|
<h2>{t(title)}</h2>
|
||||||
|
<p>{t(subtitle)}</p>
|
||||||
|
</header>
|
||||||
|
<Separator className="control-section-separator" />
|
||||||
|
<div className="control-section-content">{children}</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SnapshotPreview({
|
||||||
|
scene,
|
||||||
|
colors,
|
||||||
|
background,
|
||||||
|
colorFrom,
|
||||||
|
colorTo,
|
||||||
|
}: {
|
||||||
|
scene: RenderedScene
|
||||||
|
colors: RenderedColors
|
||||||
|
background: SnapshotBackground
|
||||||
|
colorFrom: string
|
||||||
|
colorTo: string
|
||||||
|
}) {
|
||||||
|
const id = useId().replace(/:/g, '')
|
||||||
|
const clipId = `${id}-clip`
|
||||||
|
const linearId = `${id}-linear`
|
||||||
|
const radialId = `${id}-radial`
|
||||||
|
const backgroundFill =
|
||||||
|
background === 'solid'
|
||||||
|
? colorFrom
|
||||||
|
: background === 'linear'
|
||||||
|
? `url(#${linearId})`
|
||||||
|
: `url(#${radialId})`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`snapshot-preview ${background === 'transparent' ? 'is-transparent' : ''}`}>
|
||||||
|
<svg viewBox="-150 -150 300 300" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<clipPath id={clipId}>
|
||||||
|
<motion.path d={scene.headPath} />
|
||||||
|
</clipPath>
|
||||||
|
<linearGradient id={linearId} x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stopColor={colorFrom} />
|
||||||
|
<stop offset="1" stopColor={colorTo} />
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id={radialId} cx="50%" cy="42%" r="70%">
|
||||||
|
<stop offset="0" stopColor={colorFrom} />
|
||||||
|
<stop offset="1" stopColor={colorTo} />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
{background !== 'transparent' && (
|
||||||
|
<rect x="-150" y="-150" width="300" height="300" fill={backgroundFill} />
|
||||||
|
)}
|
||||||
|
<motion.g style={{ x: scene.offsetX, y: scene.offsetY }}>
|
||||||
|
{scene.backPaths.map((pathValue, index) => (
|
||||||
|
<motion.path d={pathValue} fill={colors.body} key={`back-${index}`} />
|
||||||
|
))}
|
||||||
|
<motion.path d={scene.headPath} fill={colors.body} />
|
||||||
|
<g clipPath={`url(#${clipId})`}>
|
||||||
|
<motion.path d={scene.leftPath} fill={colors.eyes} opacity={scene.leftOpacity} />
|
||||||
|
<motion.path d={scene.rightPath} fill={colors.eyes} opacity={scene.rightOpacity} />
|
||||||
|
</g>
|
||||||
|
{scene.frontPaths.map((pathValue, index) => (
|
||||||
|
<motion.path d={pathValue} fill={colors.body} key={`front-${index}`} />
|
||||||
|
))}
|
||||||
|
</motion.g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportSection({
|
||||||
|
value,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const { t } = useStudioLanguage()
|
||||||
|
return (
|
||||||
|
<AccordionItem value={value} className="export-accordion-item">
|
||||||
|
<AccordionTrigger className="export-accordion-trigger">
|
||||||
|
<span>
|
||||||
|
<strong>{t(title)}</strong>
|
||||||
|
<small>{t(subtitle)}</small>
|
||||||
|
</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="export-accordion-content">
|
||||||
|
<div className="export-accordion-inner">{children}</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InspectorCard({ className, ...props }: React.ComponentProps<typeof Card>) {
|
||||||
|
return <Card className={`panel${className ? ` ${className}` : ''}`} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatePlayer({
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
onToggle,
|
||||||
|
onStop,
|
||||||
|
}: {
|
||||||
|
name: string | null
|
||||||
|
status: PlaybackStatus
|
||||||
|
onToggle: () => void
|
||||||
|
onStop: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useStudioLanguage()
|
||||||
|
if (!name) return null
|
||||||
|
const statusLabel =
|
||||||
|
status === 'playing' ? 'En lecture' : status === 'paused' ? 'En pause' : 'Arrêté'
|
||||||
|
return (
|
||||||
|
<div className="state-player" aria-label={`${t(statusLabel)} : ${name}`}>
|
||||||
|
<PlaybackIdentity name={name} status={status} />
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={t(status === 'playing' ? `Mettre ${name} en pause` : `Reprendre ${name}`)}
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
{status === 'playing' ? <Pause fill="currentColor" /> : <Play fill="currentColor" />}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label={t(`Arrêter ${name}`)} onClick={onStop}>
|
||||||
|
<Square fill="currentColor" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaybackIdentity({ name, status }: { name: string; status: PlaybackStatus }) {
|
||||||
|
const { t } = useStudioLanguage()
|
||||||
|
const statusLabel =
|
||||||
|
status === 'playing' ? 'En lecture' : status === 'paused' ? 'En pause' : 'Arrêté'
|
||||||
|
return (
|
||||||
|
<span className={`playback-identity is-${status}`}>
|
||||||
|
<i />
|
||||||
|
<span>
|
||||||
|
<small>{t(statusLabel)}</small>
|
||||||
|
<strong>{name}</strong>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PanelTitle({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
level = 2,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
level?: 2 | 3
|
||||||
|
}) {
|
||||||
|
const { t } = useStudioLanguage()
|
||||||
|
const Heading = level === 3 ? 'h3' : 'h2'
|
||||||
|
return (
|
||||||
|
<CardHeader className="panel-title">
|
||||||
|
<CardTitle as={Heading}>{t(title)}</CardTitle>
|
||||||
|
<CardDescription>{t(subtitle)}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
)
|
||||||
|
}
|
||||||
220
src/app/components/controls.tsx
Normal file
220
src/app/components/controls.tsx
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Field, FieldTitle } from '@/components/ui/field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { useStudioLanguage } from '@/i18n'
|
||||||
|
|
||||||
|
import { bounded, type NumericProps } from '@/app/studio-utils'
|
||||||
|
export function ColorField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useStudioLanguage()
|
||||||
|
const translatedLabel = t(label)
|
||||||
|
return (
|
||||||
|
<Field className="color-field">
|
||||||
|
<FieldTitle>{translatedLabel}</FieldTitle>
|
||||||
|
<span className="color-control">
|
||||||
|
<Input
|
||||||
|
aria-label={`${translatedLabel} · ${t('sélecteur')}`}
|
||||||
|
className="color-swatch"
|
||||||
|
type="color"
|
||||||
|
value={value}
|
||||||
|
onChange={event => onChange(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
aria-label={`${translatedLabel} · ${t('hexadécimal')}`}
|
||||||
|
className="color-value"
|
||||||
|
key={value}
|
||||||
|
defaultValue={value.toUpperCase()}
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={event => {
|
||||||
|
const next = event.currentTarget.value.toUpperCase()
|
||||||
|
if (/^#[0-9A-F]{6}$/.test(next)) onChange(next.toLowerCase())
|
||||||
|
}}
|
||||||
|
onBlur={event => {
|
||||||
|
if (!/^#[0-9A-F]{6}$/.test(event.currentTarget.value.toUpperCase())) {
|
||||||
|
event.currentTarget.value = value.toUpperCase()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmbientMotionField<Motion extends string>({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: Motion
|
||||||
|
options: { value: Motion; label: string }[]
|
||||||
|
onChange: (value: Motion) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useStudioLanguage()
|
||||||
|
return (
|
||||||
|
<Field className="ambient-motion-field" orientation="horizontal">
|
||||||
|
<FieldTitle>{t(label)}</FieldTitle>
|
||||||
|
<Select
|
||||||
|
value={value}
|
||||||
|
items={options.map(option => ({ value: option.value, label: t(option.label) }))}
|
||||||
|
onValueChange={next => next && onChange(next as Motion)}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label={t(label)}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map(option => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{t(option.label)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NumericField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step = 0.1,
|
||||||
|
unit = '',
|
||||||
|
onChange,
|
||||||
|
onActiveChange,
|
||||||
|
}: NumericProps) {
|
||||||
|
const { t } = useStudioLanguage()
|
||||||
|
const translatedLabel = t(label)
|
||||||
|
const dragRef = useRef<{ x: number; value: number } | null>(null)
|
||||||
|
const editingRef = useRef(false)
|
||||||
|
const [draftValue, setDraftValue] = useState(() =>
|
||||||
|
String(Number(value.toFixed(step < 0.1 ? 2 : 1)))
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingRef.current) {
|
||||||
|
setDraftValue(String(Number(value.toFixed(step < 0.1 ? 2 : 1))))
|
||||||
|
}
|
||||||
|
}, [step, value])
|
||||||
|
|
||||||
|
const commitDraftValue = (rawValue: string) => {
|
||||||
|
const parsedValue = Number(rawValue.replace(',', '.'))
|
||||||
|
const nextValue =
|
||||||
|
Number.isFinite(parsedValue) && rawValue.trim() !== ''
|
||||||
|
? bounded(parsedValue, min, max)
|
||||||
|
: value
|
||||||
|
|
||||||
|
setDraftValue(String(Number(nextValue.toFixed(step < 0.1 ? 2 : 1))))
|
||||||
|
onChange(nextValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startScrub = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
onActiveChange?.(true)
|
||||||
|
dragRef.current = { x: event.clientX, value }
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrub = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
if (!dragRef.current || !event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||||
|
const speed = event.shiftKey ? 10 : event.altKey ? 0.1 : 1
|
||||||
|
const next = dragRef.current.value + (event.clientX - dragRef.current.x) * step * speed
|
||||||
|
onChange(bounded(next, min, max))
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopScrub = () => {
|
||||||
|
dragRef.current = null
|
||||||
|
onActiveChange?.(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field className="numeric-field" orientation="horizontal">
|
||||||
|
<Button
|
||||||
|
className="scrub-label"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
title={t('Glisser horizontalement pour modifier')}
|
||||||
|
onPointerDown={startScrub}
|
||||||
|
onPointerMove={scrub}
|
||||||
|
onPointerUp={stopScrub}
|
||||||
|
onPointerCancel={stopScrub}
|
||||||
|
>
|
||||||
|
<span>{translatedLabel}</span>
|
||||||
|
<span className="scrub-icon" aria-hidden="true">
|
||||||
|
↔
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<label className="number-shell">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
aria-label={translatedLabel}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={draftValue}
|
||||||
|
onFocus={() => {
|
||||||
|
editingRef.current = true
|
||||||
|
onActiveChange?.(true)
|
||||||
|
}}
|
||||||
|
onBlur={event => {
|
||||||
|
editingRef.current = false
|
||||||
|
commitDraftValue(event.currentTarget.value)
|
||||||
|
onActiveChange?.(false)
|
||||||
|
}}
|
||||||
|
onChange={event => setDraftValue(event.currentTarget.value)}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === 'Enter') event.currentTarget.blur()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{unit}</span>
|
||||||
|
</label>
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinkButton({
|
||||||
|
linked,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
linked: boolean
|
||||||
|
onClick: () => void
|
||||||
|
label: string
|
||||||
|
}) {
|
||||||
|
const { t } = useStudioLanguage()
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="link-button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-pressed={linked}
|
||||||
|
aria-label={t(label)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.5.5l2-2a5 5 0 0 0-7-7l-1.1 1.1" />
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.5-.5l-2 2a5 5 0 0 0 7 7l1.1-1.1" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
src/app/studio-utils.ts
Normal file
140
src/app/studio-utils.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { useEffect, useEffectEvent } from 'react'
|
||||||
|
|
||||||
|
import { type StudioLanguage } from '@/i18n'
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyAvatarEyeDefaults,
|
||||||
|
defaultAvatarEyes,
|
||||||
|
type AvatarColors,
|
||||||
|
type AvatarEyeDefaults,
|
||||||
|
} from '@/features/avatar/avatars'
|
||||||
|
import { type BodyNode } from '@/features/avatar/body'
|
||||||
|
import { poseFromExpression, renderAvatar, type Expression } from '@/features/avatar/geometry'
|
||||||
|
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
||||||
|
|
||||||
|
export type Mode = 'manual' | 'expressions' | 'states' | 'export'
|
||||||
|
export type ExportFormat = 'react' | 'javascript'
|
||||||
|
export type SnapshotFormat = 'svg' | 'png'
|
||||||
|
export type PlaybackStatus = 'playing' | 'paused' | 'stopped'
|
||||||
|
|
||||||
|
export const downloadBlob = (blob: Blob, fileName: string) => {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = fileName
|
||||||
|
document.body.append(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||||
|
}
|
||||||
|
export type Side = 'Left' | 'Right'
|
||||||
|
export type NumericProps = {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
step?: number
|
||||||
|
unit?: string
|
||||||
|
onChange: (value: number) => void
|
||||||
|
onActiveChange?: (active: boolean) => void
|
||||||
|
}
|
||||||
|
export type Highlight = 'head' | 'left' | 'right' | 'both' | null
|
||||||
|
|
||||||
|
export const RETARGET_BLEND_MS = 120
|
||||||
|
export const INSPECTOR_FRAME_MS = 1000 / 24
|
||||||
|
export const AMBIENT_FRAME_MS = 1000 / 30
|
||||||
|
export const createExpressionId = () => `expression-${crypto.randomUUID()}`
|
||||||
|
export const emptyBodyNodes: BodyNode[] = []
|
||||||
|
const previewGeometryCache = new WeakMap<
|
||||||
|
Expression,
|
||||||
|
WeakMap<
|
||||||
|
SurfaceConfig,
|
||||||
|
WeakMap<BodyNode[], { positionKey: string; geometry: ReturnType<typeof renderAvatar> }>
|
||||||
|
>
|
||||||
|
>()
|
||||||
|
|
||||||
|
export const poseWithAvatarEyes = (expression: Expression, eyes: AvatarEyeDefaults) =>
|
||||||
|
poseFromExpression(applyAvatarEyeDefaults(expression, eyes))
|
||||||
|
|
||||||
|
export const getPreviewGeometry = (
|
||||||
|
expression: Expression,
|
||||||
|
surface: SurfaceConfig,
|
||||||
|
bodyNodes: BodyNode[],
|
||||||
|
eyes: AvatarEyeDefaults = defaultAvatarEyes
|
||||||
|
) => {
|
||||||
|
let surfaceCache = previewGeometryCache.get(expression)
|
||||||
|
if (!surfaceCache) {
|
||||||
|
surfaceCache = new WeakMap()
|
||||||
|
previewGeometryCache.set(expression, surfaceCache)
|
||||||
|
}
|
||||||
|
let bodyCache = surfaceCache.get(surface)
|
||||||
|
if (!bodyCache) {
|
||||||
|
bodyCache = new WeakMap()
|
||||||
|
surfaceCache.set(surface, bodyCache)
|
||||||
|
}
|
||||||
|
const positionKey = JSON.stringify(eyes)
|
||||||
|
const cached = bodyCache.get(bodyNodes)
|
||||||
|
if (cached?.positionKey === positionKey) return cached.geometry
|
||||||
|
const geometry = renderAvatar(poseWithAvatarEyes(expression, eyes), surface, 1, {
|
||||||
|
includeWire: false,
|
||||||
|
bodyNodes,
|
||||||
|
})
|
||||||
|
bodyCache.set(bodyNodes, { positionKey, geometry })
|
||||||
|
return geometry
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bounded = (value: number, min?: number, max?: number) =>
|
||||||
|
Math.min(max ?? Infinity, Math.max(min ?? -Infinity, value))
|
||||||
|
|
||||||
|
export const useEscapeToCancel = (cancel: () => void) => {
|
||||||
|
const cancelEvent = useEffectEvent(cancel)
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') cancelEvent()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
export const scaleSurface = (
|
||||||
|
surface: SurfaceConfig,
|
||||||
|
size: number,
|
||||||
|
minimums: Pick<SurfaceConfig, 'width' | 'height' | 'depth'>
|
||||||
|
) => {
|
||||||
|
const currentSize = Math.max(surface.width, surface.height, surface.depth) || 1
|
||||||
|
const factor = size / currentSize
|
||||||
|
return {
|
||||||
|
...surface,
|
||||||
|
width: bounded(surface.width * factor, minimums.width, 300),
|
||||||
|
height: bounded(surface.height * factor, minimums.height, 300),
|
||||||
|
depth: bounded(surface.depth * factor, minimums.depth, 300),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatSeconds = (milliseconds: number, language: StudioLanguage) =>
|
||||||
|
`${new Intl.NumberFormat(language === 'fr' ? 'fr-FR' : language === 'zh-CN' ? 'zh-CN' : 'en-US', {
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}).format(milliseconds / 1000)} s`
|
||||||
|
|
||||||
|
export const parseHexColor = (color: string) => {
|
||||||
|
const value = color.replace('#', '')
|
||||||
|
return [0, 2, 4].map(offset => Number.parseInt(value.slice(offset, offset + 2), 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const interpolateHexColor = (from: string, to: string, progress: number) => {
|
||||||
|
const fromChannels = parseHexColor(from)
|
||||||
|
const toChannels = parseHexColor(to)
|
||||||
|
return `#${fromChannels
|
||||||
|
.map((channel, index) =>
|
||||||
|
Math.round(channel + (toChannels[index] - channel) * progress)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, '0')
|
||||||
|
)
|
||||||
|
.join('')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveColors = (expression: Expression, colors: AvatarColors): AvatarColors => ({
|
||||||
|
body: expression.bodyColor ?? colors.body,
|
||||||
|
eyes: expression.eyeColor ?? colors.eyes,
|
||||||
|
})
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { createInitialSequences } from '../sequences'
|
import { createInitialSequences } from '@/features/animation/sequences'
|
||||||
import {
|
import {
|
||||||
advancePlaybackTimeline,
|
advancePlaybackTimeline,
|
||||||
beginPlayback,
|
beginPlayback,
|
||||||
@ -6,7 +6,7 @@ import {
|
|||||||
pausePlaybackTimeline,
|
pausePlaybackTimeline,
|
||||||
schedulePlaybackBlink,
|
schedulePlaybackBlink,
|
||||||
schedulePlaybackStep,
|
schedulePlaybackStep,
|
||||||
} from '../playback'
|
} from '@/features/animation/playback'
|
||||||
|
|
||||||
describe('State playback timeline', () => {
|
describe('State playback timeline', () => {
|
||||||
it('preserves the current sequence position when the avatar changes', () => {
|
it('preserves the current sequence position when the avatar changes', () => {
|
||||||
@ -5,8 +5,8 @@ import {
|
|||||||
normalizeSequencesForExpressions,
|
normalizeSequencesForExpressions,
|
||||||
parseSequences,
|
parseSequences,
|
||||||
remapSequencesAfterExpressionDelete,
|
remapSequencesAfterExpressionDelete,
|
||||||
} from '../sequences'
|
} from '@/features/animation/sequences'
|
||||||
import { initialExpressions } from '../presets'
|
import { initialExpressions } from '@/features/avatar/presets'
|
||||||
|
|
||||||
describe('editable avatar sequences', () => {
|
describe('editable avatar sequences', () => {
|
||||||
it('creates the idle animation with editable steps and blink settings', () => {
|
it('creates the idle animation with editable steps and blink settings', () => {
|
||||||
505
src/features/animation/components/SequenceWorkspace.tsx
Normal file
505
src/features/animation/components/SequenceWorkspace.tsx
Normal file
@ -0,0 +1,505 @@
|
|||||||
|
import { ArrowLeft, Copy, GripVertical, Pause, Pencil, Play, Square, Trash2 } from 'lucide-react'
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { useRef, type RefObject } from 'react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from '@/components/ui/context-menu'
|
||||||
|
import { Field, FieldTitle } from '@/components/ui/field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { useStudioLanguage } from '@/i18n'
|
||||||
|
|
||||||
|
import { ControlSection, InspectorCard, PanelTitle } from '@/app/components/common'
|
||||||
|
import { NumericField } from '@/app/components/controls'
|
||||||
|
import {
|
||||||
|
createSequenceStep,
|
||||||
|
findExpressionIndex,
|
||||||
|
type AvatarSequence,
|
||||||
|
type SequenceStep,
|
||||||
|
} from '@/features/animation/sequences'
|
||||||
|
import { type AvatarColors, type AvatarEyeDefaults } from '@/features/avatar/avatars'
|
||||||
|
import { type BodyNode } from '@/features/avatar/body'
|
||||||
|
import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
|
||||||
|
import { type Expression } from '@/features/avatar/geometry'
|
||||||
|
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
||||||
|
export function SequenceWorkspace({
|
||||||
|
editing,
|
||||||
|
expressions,
|
||||||
|
surface,
|
||||||
|
bodyNodes,
|
||||||
|
colors,
|
||||||
|
avatarEyes,
|
||||||
|
selectedStepId,
|
||||||
|
backButtonRef,
|
||||||
|
reduceMotion,
|
||||||
|
onSelectedStepChange,
|
||||||
|
onChange,
|
||||||
|
onPreviewStep,
|
||||||
|
onEditExpression,
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onStop,
|
||||||
|
playing,
|
||||||
|
active,
|
||||||
|
onCancel,
|
||||||
|
onSave,
|
||||||
|
onDuplicate,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
editing: { sourceId: string | null; draft: AvatarSequence }
|
||||||
|
expressions: Expression[]
|
||||||
|
surface: SurfaceConfig
|
||||||
|
bodyNodes: BodyNode[]
|
||||||
|
colors: AvatarColors
|
||||||
|
avatarEyes: AvatarEyeDefaults
|
||||||
|
selectedStepId: string | null
|
||||||
|
backButtonRef: RefObject<HTMLButtonElement | null>
|
||||||
|
reduceMotion: boolean
|
||||||
|
onSelectedStepChange: (id: string | null) => void
|
||||||
|
onChange: (draft: AvatarSequence) => void
|
||||||
|
onPreviewStep: (step: SequenceStep) => void
|
||||||
|
onEditExpression: (index: number, expression: Expression) => void
|
||||||
|
onPlay: () => void
|
||||||
|
onPause: () => void
|
||||||
|
onStop: () => void
|
||||||
|
playing: boolean
|
||||||
|
active: boolean
|
||||||
|
onCancel: () => void
|
||||||
|
onSave: () => void
|
||||||
|
onDuplicate: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useStudioLanguage()
|
||||||
|
const draggedStepId = useRef<string | null>(null)
|
||||||
|
const selectedStep =
|
||||||
|
editing.draft.steps.find(step => step.id === selectedStepId) ?? editing.draft.steps[0]
|
||||||
|
|
||||||
|
const updateStep = (changes: Partial<SequenceStep>) => {
|
||||||
|
if (!selectedStep) return
|
||||||
|
onChange({
|
||||||
|
...editing.draft,
|
||||||
|
steps: editing.draft.steps.map(step =>
|
||||||
|
step.id === selectedStep.id ? { ...step, ...changes } : step
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveStep = (targetId: string) => {
|
||||||
|
const draggedId = draggedStepId.current
|
||||||
|
if (!draggedId || draggedId === targetId) return
|
||||||
|
const dragged = editing.draft.steps.find(step => step.id === draggedId)
|
||||||
|
const targetIndex = editing.draft.steps.findIndex(step => step.id === targetId)
|
||||||
|
if (!dragged || targetIndex < 0) return
|
||||||
|
const next = editing.draft.steps.filter(step => step.id !== draggedId)
|
||||||
|
next.splice(targetIndex, 0, dragged)
|
||||||
|
onChange({ ...editing.draft, steps: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="workspace-header">
|
||||||
|
<Button
|
||||||
|
ref={backButtonRef}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onCancel}
|
||||||
|
aria-label={t('Retour aux animations')}
|
||||||
|
>
|
||||||
|
<ArrowLeft />
|
||||||
|
</Button>
|
||||||
|
<div className="workspace-heading">
|
||||||
|
<p className="eyebrow">{t('Éditeur d’animation')}</p>
|
||||||
|
<h1>{editing.sourceId ? t('Modifier l’animation') : t('Nouvelle animation')}</h1>
|
||||||
|
<p>{t('Compose les expressions, leur cadence et les clignements de cette animation.')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-header-actions">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={active && playing ? onPause : onPlay}
|
||||||
|
aria-label={t(
|
||||||
|
active && playing ? 'Pause' : active ? 'Reprendre' : 'Prévisualiser l’animation'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active && playing ? <Pause /> : <Play fill="currentColor" />}
|
||||||
|
</Button>
|
||||||
|
{active && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={onStop}
|
||||||
|
aria-label={t('Arrêter l’animation')}
|
||||||
|
>
|
||||||
|
<Square fill="currentColor" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="workspace-scroll sequence-workspace-scroll">
|
||||||
|
<ControlSection
|
||||||
|
title="Identité"
|
||||||
|
subtitle="Nom, catégorie et comportement de lecture de l’animation."
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<InspectorCard>
|
||||||
|
<Field>
|
||||||
|
<FieldTitle>{t('Nom')}</FieldTitle>
|
||||||
|
<Input
|
||||||
|
value={editing.draft.name}
|
||||||
|
onChange={event => onChange({ ...editing.draft, name: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldTitle>{t('Catégorie')}</FieldTitle>
|
||||||
|
<Select
|
||||||
|
value={editing.draft.group}
|
||||||
|
items={[
|
||||||
|
{ value: 'Cycle de vie', label: t('Cycle de vie') },
|
||||||
|
{ value: 'Réactions', label: t('Réactions') },
|
||||||
|
{ value: 'Custom', label: t('Custom') },
|
||||||
|
]}
|
||||||
|
onValueChange={value => value && onChange({ ...editing.draft, group: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Cycle de vie">{t('Cycle de vie')}</SelectItem>
|
||||||
|
<SelectItem value="Réactions">{t('Réactions')}</SelectItem>
|
||||||
|
<SelectItem value="Custom">{t('Custom')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldTitle>{t('Description')}</FieldTitle>
|
||||||
|
<Input
|
||||||
|
value={editing.draft.description}
|
||||||
|
onChange={event =>
|
||||||
|
onChange({ ...editing.draft, description: event.currentTarget.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldTitle>{t('Mode de lecture')}</FieldTitle>
|
||||||
|
<Select
|
||||||
|
value={editing.draft.playbackMode}
|
||||||
|
items={[
|
||||||
|
{ value: 'loop', label: t('Boucle') },
|
||||||
|
{ value: 'once', label: t('Une fois') },
|
||||||
|
{ value: 'pingPong', label: t('Aller-retour') },
|
||||||
|
]}
|
||||||
|
onValueChange={value =>
|
||||||
|
value &&
|
||||||
|
onChange({
|
||||||
|
...editing.draft,
|
||||||
|
playbackMode: value as AvatarSequence['playbackMode'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="loop">{t('Boucle')}</SelectItem>
|
||||||
|
<SelectItem value="once">{t('Une fois')}</SelectItem>
|
||||||
|
<SelectItem value="pingPong">{t('Aller-retour')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</InspectorCard>
|
||||||
|
</ControlSection>
|
||||||
|
|
||||||
|
<ControlSection
|
||||||
|
title="Timeline"
|
||||||
|
subtitle="Glisse les étapes pour les réordonner, puis sélectionne-en une pour régler sa cadence."
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<InspectorCard>
|
||||||
|
{editing.draft.steps.length ? (
|
||||||
|
<div className="sequence-timeline">
|
||||||
|
{editing.draft.steps.map((step, position) => {
|
||||||
|
const expressionIndex = findExpressionIndex(expressions, step.expressionId)
|
||||||
|
const preset = expressions[expressionIndex]
|
||||||
|
if (!preset) return null
|
||||||
|
const card = (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
aria-pressed={selectedStep?.id === step.id}
|
||||||
|
onClick={() => {
|
||||||
|
onSelectedStepChange(step.id)
|
||||||
|
onPreviewStep(step)
|
||||||
|
}}
|
||||||
|
onDoubleClick={() => onEditExpression(expressionIndex, preset)}
|
||||||
|
>
|
||||||
|
<GripVertical className="sequence-grip" />
|
||||||
|
<ExpressionPreview
|
||||||
|
expression={preset}
|
||||||
|
surface={surface}
|
||||||
|
bodyNodes={bodyNodes}
|
||||||
|
colors={colors}
|
||||||
|
avatarEyes={avatarEyes}
|
||||||
|
id={`sequence-${editing.draft.id}-${step.id}`}
|
||||||
|
/>
|
||||||
|
<span>{String(expressionIndex).padStart(2, '0')}</span>
|
||||||
|
<small>{position + 1}</small>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="sequence-step"
|
||||||
|
data-selected={selectedStep?.id === step.id || undefined}
|
||||||
|
key={step.id}
|
||||||
|
layout="position"
|
||||||
|
transition={
|
||||||
|
reduceMotion
|
||||||
|
? { duration: 0 }
|
||||||
|
: { type: 'spring', stiffness: 500, damping: 40 }
|
||||||
|
}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => {
|
||||||
|
draggedStepId.current = step.id
|
||||||
|
onSelectedStepChange(step.id)
|
||||||
|
}}
|
||||||
|
onDragEnter={() => moveStep(step.id)}
|
||||||
|
onDragOver={event => event.preventDefault()}
|
||||||
|
onDragEnd={() => {
|
||||||
|
draggedStepId.current = null
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger render={card} />
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => onEditExpression(expressionIndex, preset)}
|
||||||
|
>
|
||||||
|
<Pencil /> {t('Modifier')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="sequence-empty">{t('Ajoute une expression pour commencer.')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedStep ? (
|
||||||
|
<div className="sequence-step-settings">
|
||||||
|
<div className="state-section-heading">
|
||||||
|
<div>
|
||||||
|
<h3>{t('Étape sélectionnée')}</h3>
|
||||||
|
<p>{t('Durée visible avant de passer à l’expression suivante.')}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => {
|
||||||
|
const next = editing.draft.steps.filter(step => step.id !== selectedStep.id)
|
||||||
|
onChange({ ...editing.draft, steps: next })
|
||||||
|
onSelectedStepChange(next[0]?.id ?? null)
|
||||||
|
}}
|
||||||
|
aria-label={t('Supprimer cette étape')}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<NumericField
|
||||||
|
label="Temps d’affichage"
|
||||||
|
value={selectedStep.holdMs}
|
||||||
|
min={100}
|
||||||
|
max={60000}
|
||||||
|
step={100}
|
||||||
|
unit="ms"
|
||||||
|
onChange={holdMs => updateStep({ holdMs })}
|
||||||
|
/>
|
||||||
|
<NumericField
|
||||||
|
label="Durée de transition"
|
||||||
|
value={selectedStep.transitionMs}
|
||||||
|
min={0}
|
||||||
|
max={5000}
|
||||||
|
step={50}
|
||||||
|
unit="ms"
|
||||||
|
onChange={transitionMs => updateStep({ transitionMs })}
|
||||||
|
/>
|
||||||
|
<Field className="sequence-transition-field" orientation="horizontal">
|
||||||
|
<FieldTitle>{t('Transition')}</FieldTitle>
|
||||||
|
<Select
|
||||||
|
value={selectedStep.transition}
|
||||||
|
items={[
|
||||||
|
{ value: 'spring', label: t('Ressort') },
|
||||||
|
{ value: 'smooth', label: t('Douce') },
|
||||||
|
{ value: 'snappy', label: t('Rapide') },
|
||||||
|
]}
|
||||||
|
onValueChange={value =>
|
||||||
|
value && updateStep({ transition: value as SequenceStep['transition'] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="spring">{t('Ressort')}</SelectItem>
|
||||||
|
<SelectItem value="smooth">{t('Douce')}</SelectItem>
|
||||||
|
<SelectItem value="snappy">{t('Rapide')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</InspectorCard>
|
||||||
|
|
||||||
|
<InspectorCard>
|
||||||
|
<PanelTitle
|
||||||
|
level={3}
|
||||||
|
title="Ajouter une expression"
|
||||||
|
subtitle="Sélectionne un preset pour l’ajouter à la fin de la timeline."
|
||||||
|
/>
|
||||||
|
<div className="expression-grid sequence-expression-library">
|
||||||
|
{expressions.map((preset, index) => (
|
||||||
|
<Button
|
||||||
|
className="expression-card"
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
key={index}
|
||||||
|
onClick={() => {
|
||||||
|
const step = createSequenceStep(preset.id)
|
||||||
|
onChange({ ...editing.draft, steps: [...editing.draft.steps, step] })
|
||||||
|
onSelectedStepChange(step.id)
|
||||||
|
onPreviewStep(step)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExpressionPreview
|
||||||
|
expression={preset}
|
||||||
|
surface={surface}
|
||||||
|
bodyNodes={bodyNodes}
|
||||||
|
colors={colors}
|
||||||
|
avatarEyes={avatarEyes}
|
||||||
|
id={`sequence-library-${index}`}
|
||||||
|
/>
|
||||||
|
<span>{String(index).padStart(2, '0')}</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</InspectorCard>
|
||||||
|
</ControlSection>
|
||||||
|
|
||||||
|
<ControlSection
|
||||||
|
title="Clignements"
|
||||||
|
subtitle="Le blink fonctionne indépendamment des changements d’expression."
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<InspectorCard>
|
||||||
|
<div className="switch">
|
||||||
|
<span>{t('Activer les clignements')}</span>
|
||||||
|
<Switch
|
||||||
|
checked={editing.draft.blink.enabled}
|
||||||
|
onCheckedChange={enabled =>
|
||||||
|
onChange({
|
||||||
|
...editing.draft,
|
||||||
|
blink: { ...editing.draft.blink, enabled },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<NumericField
|
||||||
|
label="Premier clignement"
|
||||||
|
value={editing.draft.blink.initialDelayMs}
|
||||||
|
min={0}
|
||||||
|
max={60000}
|
||||||
|
step={100}
|
||||||
|
unit="ms"
|
||||||
|
onChange={initialDelayMs =>
|
||||||
|
onChange({
|
||||||
|
...editing.draft,
|
||||||
|
blink: { ...editing.draft.blink, initialDelayMs },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="eye-columns">
|
||||||
|
<NumericField
|
||||||
|
label="Intervalle minimum"
|
||||||
|
value={editing.draft.blink.minIntervalMs}
|
||||||
|
min={100}
|
||||||
|
max={60000}
|
||||||
|
step={100}
|
||||||
|
unit="ms"
|
||||||
|
onChange={minIntervalMs =>
|
||||||
|
onChange({
|
||||||
|
...editing.draft,
|
||||||
|
blink: {
|
||||||
|
...editing.draft.blink,
|
||||||
|
minIntervalMs,
|
||||||
|
maxIntervalMs: Math.max(minIntervalMs, editing.draft.blink.maxIntervalMs),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<NumericField
|
||||||
|
label="Intervalle maximum"
|
||||||
|
value={editing.draft.blink.maxIntervalMs}
|
||||||
|
min={editing.draft.blink.minIntervalMs}
|
||||||
|
max={60000}
|
||||||
|
step={100}
|
||||||
|
unit="ms"
|
||||||
|
onChange={maxIntervalMs =>
|
||||||
|
onChange({
|
||||||
|
...editing.draft,
|
||||||
|
blink: { ...editing.draft.blink, maxIntervalMs },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<NumericField
|
||||||
|
label="Durée du clignement"
|
||||||
|
value={editing.draft.blink.durationMs}
|
||||||
|
min={40}
|
||||||
|
max={3000}
|
||||||
|
step={20}
|
||||||
|
unit="ms"
|
||||||
|
onChange={durationMs =>
|
||||||
|
onChange({
|
||||||
|
...editing.draft,
|
||||||
|
blink: { ...editing.draft.blink, durationMs },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</InspectorCard>
|
||||||
|
</ControlSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="workspace-footer">
|
||||||
|
<div className="workspace-footer-secondary">
|
||||||
|
{editing.sourceId && (
|
||||||
|
<Button variant="destructive" onClick={onDelete}>
|
||||||
|
<Trash2 /> {t('Supprimer')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" onClick={onDuplicate}>
|
||||||
|
<Copy /> {t('Dupliquer')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
disabled={!editing.draft.steps.length || !editing.draft.name.trim()}
|
||||||
|
onClick={onSave}
|
||||||
|
>
|
||||||
|
{t('Enregistrer')}
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -4,8 +4,8 @@ import {
|
|||||||
stateGroups,
|
stateGroups,
|
||||||
stateNotes,
|
stateNotes,
|
||||||
statePools,
|
statePools,
|
||||||
} from './presets'
|
} from '../avatar/presets'
|
||||||
import type { Expression } from './geometry'
|
import type { Expression } from '../avatar/geometry'
|
||||||
|
|
||||||
export type SequencePlaybackMode = 'loop' | 'once' | 'pingPong'
|
export type SequencePlaybackMode = 'loop' | 'once' | 'pingPong'
|
||||||
export type SequenceTransition = 'spring' | 'smooth' | 'snappy'
|
export type SequenceTransition = 'spring' | 'smooth' | 'snappy'
|
||||||
@ -1,5 +1,9 @@
|
|||||||
import { ambientBodyOffset, applyAmbientMotion, hasAmbientMotion } from '../ambientMotion'
|
import {
|
||||||
import { defaultExpression } from '../presets'
|
ambientBodyOffset,
|
||||||
|
applyAmbientMotion,
|
||||||
|
hasAmbientMotion,
|
||||||
|
} from '@/features/avatar/ambientMotion'
|
||||||
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
|
|
||||||
describe('perpetual expression motion', () => {
|
describe('perpetual expression motion', () => {
|
||||||
it('leaves a motionless expression unchanged', () => {
|
it('leaves a motionless expression unchanged', () => {
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { defaultExpression } from '../presets'
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
import { createInitialSequences } from '../sequences'
|
import { createInitialSequences } from '@/features/animation/sequences'
|
||||||
import {
|
import {
|
||||||
applyAvatarEyeDefaults,
|
applyAvatarEyeDefaults,
|
||||||
cloneAvatarBehavior,
|
cloneAvatarBehavior,
|
||||||
@ -7,8 +7,8 @@ import {
|
|||||||
defaultAvatarEyes,
|
defaultAvatarEyes,
|
||||||
parseAvatarEyeDefaults,
|
parseAvatarEyeDefaults,
|
||||||
resolveAvatarBehavior,
|
resolveAvatarBehavior,
|
||||||
} from '../avatars'
|
} from '@/features/avatar/avatars'
|
||||||
import { initialExpressions } from '../presets'
|
import { initialExpressions } from '@/features/avatar/presets'
|
||||||
|
|
||||||
describe('avatar eye defaults', () => {
|
describe('avatar eye defaults', () => {
|
||||||
it('keeps the historical rendering when using default values', () => {
|
it('keeps the historical rendering when using default values', () => {
|
||||||
@ -1,5 +1,9 @@
|
|||||||
import { updateEyeDimension, scaleEye, updateEyePosition } from '../expressionEditing'
|
import {
|
||||||
import { defaultExpression } from '../presets'
|
updateEyeDimension,
|
||||||
|
scaleEye,
|
||||||
|
updateEyePosition,
|
||||||
|
} from '@/features/avatar/expressionEditing'
|
||||||
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
|
|
||||||
describe('Expression eye editing', () => {
|
describe('Expression eye editing', () => {
|
||||||
it('applies linked dimensions through one invariant-preserving operation', () => {
|
it('applies linked dimensions through one invariant-preserving operation', () => {
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import type { BodyNode } from '../body'
|
import type { BodyNode } from '@/features/avatar/body'
|
||||||
import {
|
import {
|
||||||
poseFromExpression,
|
poseFromExpression,
|
||||||
renderAvatar,
|
renderAvatar,
|
||||||
@ -6,9 +6,9 @@ import {
|
|||||||
rotateBodyNodeAroundLocalAxis,
|
rotateBodyNodeAroundLocalAxis,
|
||||||
translateBodyNodeInCameraPlane,
|
translateBodyNodeInCameraPlane,
|
||||||
translateBodyNodeAlongLocalAxis,
|
translateBodyNodeAlongLocalAxis,
|
||||||
} from '../geometry'
|
} from '@/features/avatar/geometry'
|
||||||
import { defaultExpression } from '../presets'
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
import { surfacePresets } from '../surfaces'
|
import { surfacePresets } from '@/features/avatar/surfaces'
|
||||||
|
|
||||||
const node: BodyNode = {
|
const node: BodyNode = {
|
||||||
id: 'shape-test',
|
id: 'shape-test',
|
||||||
@ -3,7 +3,7 @@ import {
|
|||||||
beginManipulationFromRenderedValue,
|
beginManipulationFromRenderedValue,
|
||||||
finishManipulation,
|
finishManipulation,
|
||||||
previewManipulation,
|
previewManipulation,
|
||||||
} from '../manipulationSession'
|
} from '@/features/avatar/manipulationSession'
|
||||||
|
|
||||||
describe('pointer manipulation session', () => {
|
describe('pointer manipulation session', () => {
|
||||||
it('previews many values and commits exactly once', () => {
|
it('previews many values and commits exactly once', () => {
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { getStatePlaybackConfig } from '../presets'
|
import { getStatePlaybackConfig } from '@/features/avatar/presets'
|
||||||
|
|
||||||
describe('state playback configuration', () => {
|
describe('state playback configuration', () => {
|
||||||
it('keeps idle slower than an active sequence', () => {
|
it('keeps idle slower than an active sequence', () => {
|
||||||
@ -3,7 +3,11 @@ import { defaultExpression, initialExpressions } from './presets'
|
|||||||
import { surfacePresets } from './surfaces'
|
import { surfacePresets } from './surfaces'
|
||||||
import type { Expression } from './geometry'
|
import type { Expression } from './geometry'
|
||||||
import { isBodyMotion, isEyeMotion } from './ambientMotion'
|
import { isBodyMotion, isEyeMotion } from './ambientMotion'
|
||||||
import { normalizeSequencesForExpressions, parseSequences, type AvatarSequence } from './sequences'
|
import {
|
||||||
|
normalizeSequencesForExpressions,
|
||||||
|
parseSequences,
|
||||||
|
type AvatarSequence,
|
||||||
|
} from '../animation/sequences'
|
||||||
|
|
||||||
export type AvatarBehaviorLibrary = {
|
export type AvatarBehaviorLibrary = {
|
||||||
expressions: Expression[]
|
expressions: Expression[]
|
||||||
496
src/features/avatar/components/ExpressionWorkspace.tsx
Normal file
496
src/features/avatar/components/ExpressionWorkspace.tsx
Normal file
@ -0,0 +1,496 @@
|
|||||||
|
import { ArrowLeft, Copy, Pencil, RotateCcw, Trash2 } from 'lucide-react'
|
||||||
|
import { useState, type RefObject } from 'react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from '@/components/ui/context-menu'
|
||||||
|
import { useStudioLanguage } from '@/i18n'
|
||||||
|
|
||||||
|
import { ControlSection } from '@/app/components/common'
|
||||||
|
import { AmbientMotionField, ColorField, LinkButton, NumericField } from '@/app/components/controls'
|
||||||
|
import { emptyBodyNodes, getPreviewGeometry, resolveColors, type Side } from '@/app/studio-utils'
|
||||||
|
import { type AvatarColors, type AvatarEyeDefaults } from '@/features/avatar/avatars'
|
||||||
|
import { type BodyNode } from '@/features/avatar/body'
|
||||||
|
import { scaleEye, updateEyeDimension } from '@/features/avatar/expressionEditing'
|
||||||
|
import { type Expression } from '@/features/avatar/geometry'
|
||||||
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
|
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
||||||
|
export function SurfaceThumbnail({ surface }: { surface: SurfaceConfig }) {
|
||||||
|
const geometry = getPreviewGeometry(defaultExpression, surface, emptyBodyNodes)
|
||||||
|
return (
|
||||||
|
<svg viewBox="-150 -150 300 300" aria-hidden="true">
|
||||||
|
{geometry.backPaths.map((pathValue, index) => (
|
||||||
|
<path d={pathValue} key={index} />
|
||||||
|
))}
|
||||||
|
<path d={geometry.headPath} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpressionPreview({
|
||||||
|
expression,
|
||||||
|
surface,
|
||||||
|
bodyNodes,
|
||||||
|
colors,
|
||||||
|
avatarEyes,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
expression: Expression
|
||||||
|
surface: SurfaceConfig
|
||||||
|
bodyNodes: BodyNode[]
|
||||||
|
colors: AvatarColors
|
||||||
|
avatarEyes: AvatarEyeDefaults
|
||||||
|
id: string
|
||||||
|
}) {
|
||||||
|
const geometry = getPreviewGeometry(expression, surface, bodyNodes, avatarEyes)
|
||||||
|
const resolvedColors = resolveColors(expression, colors)
|
||||||
|
const clipId = `preview-${id}`
|
||||||
|
return (
|
||||||
|
<svg viewBox="-150 -150 300 300" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<clipPath id={clipId}>
|
||||||
|
<path d={geometry.headPath} />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
{geometry.backPaths.map((pathValue, index) => (
|
||||||
|
<path
|
||||||
|
className="preview-head"
|
||||||
|
d={pathValue}
|
||||||
|
key={index}
|
||||||
|
style={{ fill: resolvedColors.body }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<path className="preview-head" d={geometry.headPath} style={{ fill: resolvedColors.body }} />
|
||||||
|
<g clipPath={`url(#${clipId})`}>
|
||||||
|
<path
|
||||||
|
className="preview-eye"
|
||||||
|
d={geometry.leftPath}
|
||||||
|
opacity={geometry.leftVisible ? 1 : 0}
|
||||||
|
style={{ fill: resolvedColors.eyes }}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="preview-eye"
|
||||||
|
d={geometry.rightPath}
|
||||||
|
opacity={geometry.rightVisible ? 1 : 0}
|
||||||
|
style={{ fill: resolvedColors.eyes }}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
{geometry.frontPaths.map((pathValue, index) => (
|
||||||
|
<path
|
||||||
|
className="preview-head"
|
||||||
|
d={pathValue}
|
||||||
|
key={`front-${index}`}
|
||||||
|
style={{ fill: resolvedColors.body }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpressionCard({
|
||||||
|
expression,
|
||||||
|
index,
|
||||||
|
active,
|
||||||
|
surface,
|
||||||
|
bodyNodes,
|
||||||
|
colors,
|
||||||
|
avatarEyes,
|
||||||
|
previewId,
|
||||||
|
onSelect,
|
||||||
|
onEdit,
|
||||||
|
onDuplicate,
|
||||||
|
onDelete,
|
||||||
|
draggable,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnter,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
onDragEnd,
|
||||||
|
}: {
|
||||||
|
expression: Expression
|
||||||
|
index: number
|
||||||
|
active: boolean
|
||||||
|
surface: SurfaceConfig
|
||||||
|
bodyNodes: BodyNode[]
|
||||||
|
colors: AvatarColors
|
||||||
|
avatarEyes: AvatarEyeDefaults
|
||||||
|
previewId: string
|
||||||
|
onSelect: () => void
|
||||||
|
onEdit?: () => void
|
||||||
|
onDuplicate?: () => void
|
||||||
|
onDelete?: () => void
|
||||||
|
draggable?: boolean
|
||||||
|
onDragStart?: (event: React.DragEvent<HTMLButtonElement>) => void
|
||||||
|
onDragEnter?: () => void
|
||||||
|
onDragOver?: (event: React.DragEvent<HTMLButtonElement>) => void
|
||||||
|
onDrop?: (event: React.DragEvent<HTMLButtonElement>) => void
|
||||||
|
onDragEnd?: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useStudioLanguage()
|
||||||
|
const card = (
|
||||||
|
<Button
|
||||||
|
className="expression-card"
|
||||||
|
variant="outline"
|
||||||
|
aria-pressed={active}
|
||||||
|
type="button"
|
||||||
|
draggable={draggable}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnter={onDragEnter}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onClick={onSelect}
|
||||||
|
onDoubleClick={onEdit}
|
||||||
|
>
|
||||||
|
<ExpressionPreview
|
||||||
|
expression={expression}
|
||||||
|
surface={surface}
|
||||||
|
bodyNodes={bodyNodes}
|
||||||
|
colors={colors}
|
||||||
|
avatarEyes={avatarEyes}
|
||||||
|
id={previewId}
|
||||||
|
/>
|
||||||
|
<span>{String(index).padStart(2, '0')}</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
if (!onEdit) return card
|
||||||
|
return (
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger render={card} />
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem onClick={onEdit}>
|
||||||
|
<Pencil /> {t('Modifier')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
{onDuplicate && (
|
||||||
|
<ContextMenuItem onClick={onDuplicate}>
|
||||||
|
<Copy /> {t('Dupliquer')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem variant="destructive" onClick={onDelete}>
|
||||||
|
<Trash2 /> {t('Supprimer')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpressionWorkspace({
|
||||||
|
editing,
|
||||||
|
avatarColors,
|
||||||
|
backButtonRef,
|
||||||
|
onChange,
|
||||||
|
onCancel,
|
||||||
|
onSave,
|
||||||
|
onDuplicate,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
editing: { index: number | null; draft: Expression }
|
||||||
|
avatarColors: AvatarColors
|
||||||
|
backButtonRef: RefObject<HTMLButtonElement | null>
|
||||||
|
onChange: (draft: Expression) => void
|
||||||
|
onCancel: () => void
|
||||||
|
onSave: () => void
|
||||||
|
onDuplicate: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useStudioLanguage()
|
||||||
|
const [linked, setLinked] = useState({
|
||||||
|
width: true,
|
||||||
|
height: true,
|
||||||
|
size: true,
|
||||||
|
rotation: true,
|
||||||
|
})
|
||||||
|
const update = (changes: Partial<Expression>) => onChange({ ...editing.draft, ...changes })
|
||||||
|
const updateDimension = (side: Side, dimension: 'width' | 'height', value: number) => {
|
||||||
|
onChange(updateEyeDimension(editing.draft, side, dimension, value, linked[dimension]))
|
||||||
|
}
|
||||||
|
const updateSize = (side: Side, value: number) => {
|
||||||
|
onChange(scaleEye(editing.draft, side, value, linked.size))
|
||||||
|
}
|
||||||
|
const updateRotation = (side: Side, value: number) => {
|
||||||
|
onChange({
|
||||||
|
...editing.draft,
|
||||||
|
[side === 'Left' ? 'leftAngle' : 'rightAngle']: value,
|
||||||
|
...(linked.rotation ? { [side === 'Left' ? 'rightAngle' : 'leftAngle']: -value } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="workspace-header">
|
||||||
|
<Button
|
||||||
|
ref={backButtonRef}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onCancel}
|
||||||
|
aria-label={t('Retour aux expressions')}
|
||||||
|
>
|
||||||
|
<ArrowLeft />
|
||||||
|
</Button>
|
||||||
|
<div className="workspace-heading">
|
||||||
|
<p className="eyebrow">{t('Preset en mémoire')}</p>
|
||||||
|
<h1>
|
||||||
|
{editing.index === null
|
||||||
|
? t('Nouvelle expression')
|
||||||
|
: t(`Modifier l’expression ${String(editing.index).padStart(2, '0')}`)}
|
||||||
|
</h1>
|
||||||
|
<p>{t('L’avatar à gauche affiche cette expression en direct.')}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="workspace-scroll">
|
||||||
|
<div className="dialog-fields">
|
||||||
|
<ControlSection
|
||||||
|
title="Corps"
|
||||||
|
subtitle="Apparence et orientation générale de l’avatar."
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<Card className="dialog-group color-panel">
|
||||||
|
<h3>{t('Couleur du corps')}</h3>
|
||||||
|
<ColorField
|
||||||
|
label="Corps"
|
||||||
|
value={editing.draft.bodyColor ?? avatarColors.body}
|
||||||
|
onChange={bodyColor => update({ bodyColor })}
|
||||||
|
/>
|
||||||
|
{editing.draft.bodyColor && (
|
||||||
|
<Button
|
||||||
|
className="inherit-colors"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={t('Reprendre la couleur de l’avatar')}
|
||||||
|
onClick={() => {
|
||||||
|
const draft = { ...editing.draft }
|
||||||
|
delete draft.bodyColor
|
||||||
|
onChange(draft)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
<Card className="dialog-group">
|
||||||
|
<h3>{t('Mouvement perpétuel')}</h3>
|
||||||
|
<AmbientMotionField
|
||||||
|
label="Corps"
|
||||||
|
value={editing.draft.bodyMotion}
|
||||||
|
options={[
|
||||||
|
{ value: 'none', label: 'Aucun mouvement' },
|
||||||
|
{ value: 'slowDrift', label: 'Dérive lente' },
|
||||||
|
{ value: 'shake', label: 'Tremblement' },
|
||||||
|
]}
|
||||||
|
onChange={bodyMotion => update({ bodyMotion })}
|
||||||
|
/>
|
||||||
|
<p className="field-help">
|
||||||
|
{t('Ajoute une légère présence ou un tremblement continu au corps.')}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="dialog-group color-panel">
|
||||||
|
<h3>{t('Rotation de la tête')}</h3>
|
||||||
|
{(['headX', 'headY', 'headZ'] as const).map(field => (
|
||||||
|
<NumericField
|
||||||
|
key={field}
|
||||||
|
label={`Rotation ${field.at(-1)?.toUpperCase()}`}
|
||||||
|
value={editing.draft[field]}
|
||||||
|
unit="°"
|
||||||
|
onChange={value => update({ [field]: value })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</ControlSection>
|
||||||
|
<ControlSection
|
||||||
|
title="Yeux"
|
||||||
|
subtitle="Forme, placement et orientation propres au regard."
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<Card className="dialog-group color-panel">
|
||||||
|
<h3>{t('Couleur des yeux')}</h3>
|
||||||
|
<ColorField
|
||||||
|
label="Yeux"
|
||||||
|
value={editing.draft.eyeColor ?? avatarColors.eyes}
|
||||||
|
onChange={eyeColor => update({ eyeColor })}
|
||||||
|
/>
|
||||||
|
{editing.draft.eyeColor && (
|
||||||
|
<Button
|
||||||
|
className="inherit-colors"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={t('Reprendre la couleur de l’avatar')}
|
||||||
|
onClick={() => {
|
||||||
|
const draft = { ...editing.draft }
|
||||||
|
delete draft.eyeColor
|
||||||
|
onChange(draft)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
<Card className="dialog-group">
|
||||||
|
<h3>{t('Mouvement perpétuel')}</h3>
|
||||||
|
<AmbientMotionField
|
||||||
|
label="Yeux"
|
||||||
|
value={editing.draft.eyeMotion}
|
||||||
|
options={[
|
||||||
|
{ value: 'none', label: 'Aucun mouvement' },
|
||||||
|
{ value: 'microSaccades', label: 'Micro-ajustements' },
|
||||||
|
{ value: 'shake', label: 'Tremblement' },
|
||||||
|
]}
|
||||||
|
onChange={eyeMotion => update({ eyeMotion })}
|
||||||
|
/>
|
||||||
|
<p className="field-help">
|
||||||
|
{t('Anime le regard par petites saccades naturelles ou par tremblement.')}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
{(['width', 'height', 'size'] as const).map(dimension => (
|
||||||
|
<Card className="dialog-group" key={dimension}>
|
||||||
|
<div className="panel-inline-title">
|
||||||
|
<h3>
|
||||||
|
{t(
|
||||||
|
{ width: 'Largeur', height: 'Hauteur', size: 'Taille proportionnelle' }[
|
||||||
|
dimension
|
||||||
|
]
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<LinkButton
|
||||||
|
linked={linked[dimension]}
|
||||||
|
label={`Lier ${dimension}`}
|
||||||
|
onClick={() =>
|
||||||
|
setLinked(current => ({ ...current, [dimension]: !current[dimension] }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="eye-columns">
|
||||||
|
{(['Left', 'Right'] as Side[]).map(side => {
|
||||||
|
const width = editing.draft[`width${side}`]
|
||||||
|
const height = editing.draft[`height${side}`]
|
||||||
|
const value =
|
||||||
|
dimension === 'width'
|
||||||
|
? width
|
||||||
|
: dimension === 'height'
|
||||||
|
? height
|
||||||
|
: Math.max(width, height)
|
||||||
|
return (
|
||||||
|
<NumericField
|
||||||
|
key={side}
|
||||||
|
label={side === 'Left' ? 'Œil gauche' : 'Œil droit'}
|
||||||
|
value={value}
|
||||||
|
min={10}
|
||||||
|
max={dimension === 'size' ? 110 : 100}
|
||||||
|
unit="u"
|
||||||
|
onChange={next =>
|
||||||
|
dimension === 'size'
|
||||||
|
? updateSize(side, next)
|
||||||
|
: updateDimension(side, dimension, next)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
<Card className="dialog-group">
|
||||||
|
<h3>{t('Position et espacement')}</h3>
|
||||||
|
<div className="eye-columns">
|
||||||
|
{(['Left', 'Right'] as Side[]).map(side => (
|
||||||
|
<div className="eye-column" key={side}>
|
||||||
|
<h3>{t(side === 'Left' ? 'Œil gauche' : 'Œil droit')}</h3>
|
||||||
|
<NumericField
|
||||||
|
label="Horizontale"
|
||||||
|
value={editing.draft[`positionX${side}`]}
|
||||||
|
unit="u"
|
||||||
|
onChange={value => update({ [`positionX${side}`]: value })}
|
||||||
|
/>
|
||||||
|
<NumericField
|
||||||
|
label="Verticale"
|
||||||
|
value={editing.draft[`positionY${side}`]}
|
||||||
|
unit="u"
|
||||||
|
onChange={value => update({ [`positionY${side}`]: value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="position-spacing">
|
||||||
|
<NumericField
|
||||||
|
label="Espacement"
|
||||||
|
value={editing.draft.spacing}
|
||||||
|
min={0}
|
||||||
|
max={150}
|
||||||
|
unit="u"
|
||||||
|
onChange={value => update({ spacing: value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="dialog-group">
|
||||||
|
<div className="panel-inline-title">
|
||||||
|
<h3>{t('Rotation locale')}</h3>
|
||||||
|
<LinkButton
|
||||||
|
linked={linked.rotation}
|
||||||
|
label="Lier les rotations"
|
||||||
|
onClick={() =>
|
||||||
|
setLinked(current => ({ ...current, rotation: !current.rotation }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="eye-columns">
|
||||||
|
<NumericField
|
||||||
|
label="Œil gauche"
|
||||||
|
value={editing.draft.leftAngle}
|
||||||
|
unit="°"
|
||||||
|
onChange={value => updateRotation('Left', value)}
|
||||||
|
/>
|
||||||
|
<NumericField
|
||||||
|
label="Œil droit"
|
||||||
|
value={editing.draft.rightAngle}
|
||||||
|
unit="°"
|
||||||
|
onChange={value => updateRotation('Right', value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</ControlSection>
|
||||||
|
<ControlSection
|
||||||
|
title="Projection"
|
||||||
|
subtitle="Perspective appliquée à la surface active."
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<Card className="dialog-group">
|
||||||
|
<NumericField
|
||||||
|
label="Perspective"
|
||||||
|
value={editing.draft.perspective}
|
||||||
|
step={0.01}
|
||||||
|
unit="×"
|
||||||
|
onChange={value => update({ perspective: value })}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</ControlSection>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer className="workspace-footer">
|
||||||
|
<div className="workspace-footer-secondary">
|
||||||
|
{editing.index !== null && (
|
||||||
|
<Button variant="destructive" onClick={onDelete}>
|
||||||
|
<Trash2 />
|
||||||
|
{t('Supprimer')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" onClick={onDuplicate}>
|
||||||
|
<Copy />
|
||||||
|
{t('Dupliquer')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="dialog-actions-main">
|
||||||
|
<Button onClick={onSave}>{t('Enregistrer')}</Button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { createAvatar } from '../avatars'
|
import { createAvatar } from '@/features/avatar/avatars'
|
||||||
import { parse } from '@babel/parser'
|
import { parse } from '@babel/parser'
|
||||||
import {
|
import {
|
||||||
createAvatarExportPayload,
|
createAvatarExportPayload,
|
||||||
@ -8,9 +8,9 @@ import {
|
|||||||
generateReactAvatarComponent,
|
generateReactAvatarComponent,
|
||||||
generateReactAvatarPackage,
|
generateReactAvatarPackage,
|
||||||
generateReactAvatarRuntime,
|
generateReactAvatarRuntime,
|
||||||
} from '../exporter'
|
} from '@/features/export/exporter'
|
||||||
import { initialExpressions } from '../presets'
|
import { initialExpressions } from '@/features/avatar/presets'
|
||||||
import { createInitialSequences } from '../sequences'
|
import { createInitialSequences } from '@/features/animation/sequences'
|
||||||
|
|
||||||
describe('avatar export', () => {
|
describe('avatar export', () => {
|
||||||
const avatar = createAvatar('Strobi')
|
const avatar = createAvatar('Strobi')
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import { poseFromExpression, renderAvatar } from '../geometry'
|
import { poseFromExpression, renderAvatar } from '@/features/avatar/geometry'
|
||||||
import { defaultExpression } from '../presets'
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
import { createRenderedScene, paintRenderedOffset } from '../renderedScene'
|
import { createRenderedScene, paintRenderedOffset } from '@/features/rendering/renderedScene'
|
||||||
import { serializeAvatarSnapshot, snapshotFileName } from '../snapshotExporter'
|
import { serializeAvatarSnapshot, snapshotFileName } from '@/features/export/snapshotExporter'
|
||||||
import { surfacePresets } from '../surfaces'
|
import { surfacePresets } from '@/features/avatar/surfaces'
|
||||||
|
|
||||||
describe('avatar snapshot export', () => {
|
describe('avatar snapshot export', () => {
|
||||||
const geometry = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1)
|
const geometry = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1)
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import { applyAvatarEyeDefaults, type StudioAvatar } from './avatars'
|
import { applyAvatarEyeDefaults, type StudioAvatar } from '../avatar/avatars'
|
||||||
import type { Expression } from './geometry'
|
import type { Expression } from '../avatar/geometry'
|
||||||
import { translateStudioText, type StudioLanguage } from './i18n'
|
import { translateStudioText, type StudioLanguage } from '../../i18n'
|
||||||
import { proceduralBrowserRuntime } from './proceduralBrowserRuntime'
|
import { proceduralBrowserRuntime } from './proceduralBrowserRuntime'
|
||||||
import type { AvatarSequence } from './sequences'
|
import type { AvatarSequence } from '../animation/sequences'
|
||||||
import { standaloneEngineSource } from './standaloneEngine.generated'
|
import { standaloneEngineSource } from './standaloneEngine.generated'
|
||||||
import { createStoredZip } from './storedZip'
|
import { createStoredZip } from './storedZip'
|
||||||
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import type { AvatarColors } from './avatars'
|
import type { AvatarColors } from '../avatar/avatars'
|
||||||
import type { RenderedScene } from './renderedScene'
|
import type { RenderedScene } from '../rendering/renderedScene'
|
||||||
|
|
||||||
export type SnapshotBackground = 'transparent' | 'solid' | 'linear' | 'radial'
|
export type SnapshotBackground = 'transparent' | 'solid' | 'linear' | 'radial'
|
||||||
|
|
||||||
@ -1,2 +1,2 @@
|
|||||||
export { expressionFields, poseFromExpression, renderAvatar } from './geometry'
|
export { expressionFields, poseFromExpression, renderAvatar } from '../avatar/geometry'
|
||||||
export { ambientBodyOffset, applyAmbientMotion, hasAmbientMotion } from './ambientMotion'
|
export { ambientBodyOffset, applyAmbientMotion, hasAmbientMotion } from '../avatar/ambientMotion'
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { applyAvatarEyeDefaults, defaultAvatarEyes } from '../avatars'
|
import { applyAvatarEyeDefaults, defaultAvatarEyes } from '@/features/avatar/avatars'
|
||||||
import { resolveCanvasPreviewExpression } from '../canvasPreview'
|
import { resolveCanvasPreviewExpression } from '@/features/rendering/canvasPreview'
|
||||||
import { defaultExpression } from '../presets'
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
|
|
||||||
describe('canvas preview expression', () => {
|
describe('canvas preview expression', () => {
|
||||||
it('keeps customized neutral eyes applied while rotating the body preview', () => {
|
it('keeps customized neutral eyes applied while rotating the body preview', () => {
|
||||||
@ -1,5 +1,8 @@
|
|||||||
import { defaultExpression } from '../presets'
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
import { createRenderedRotationGizmo, paintRenderedRotationGizmo } from '../renderedRotationGizmo'
|
import {
|
||||||
|
createRenderedRotationGizmo,
|
||||||
|
paintRenderedRotationGizmo,
|
||||||
|
} from '@/features/rendering/renderedRotationGizmo'
|
||||||
|
|
||||||
describe('rendered rotation gizmo', () => {
|
describe('rendered rotation gizmo', () => {
|
||||||
it('updates its rings without requiring a React render', () => {
|
it('updates its rings without requiring a React render', () => {
|
||||||
@ -1,18 +1,18 @@
|
|||||||
import { createBodyNode } from '../body'
|
import { createBodyNode } from '@/features/avatar/body'
|
||||||
import { renderAvatar, poseFromExpression } from '../geometry'
|
import { renderAvatar, poseFromExpression } from '@/features/avatar/geometry'
|
||||||
import { defaultExpression } from '../presets'
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
import {
|
import {
|
||||||
createRenderedColors,
|
createRenderedColors,
|
||||||
createRenderedScene,
|
createRenderedScene,
|
||||||
findBodyNodePath,
|
findBodyNodePath,
|
||||||
paintRenderedColors,
|
paintRenderedColors,
|
||||||
paintRenderedScene,
|
paintRenderedScene,
|
||||||
} from '../renderedScene'
|
} from '@/features/rendering/renderedScene'
|
||||||
import { surfacePresets } from '../surfaces'
|
import { surfacePresets } from '@/features/avatar/surfaces'
|
||||||
import defaultStudioDocument from '../defaultStudioDocument.json'
|
import defaultStudioDocument from '@/features/studio/defaultStudioDocument.json'
|
||||||
import type { BodyNode } from '../body'
|
import type { BodyNode } from '@/features/avatar/body'
|
||||||
import type { Expression } from '../geometry'
|
import type { Expression } from '@/features/avatar/geometry'
|
||||||
import type { SurfaceConfig } from '../surfaces'
|
import type { SurfaceConfig } from '@/features/avatar/surfaces'
|
||||||
|
|
||||||
describe('rendered avatar scene', () => {
|
describe('rendered avatar scene', () => {
|
||||||
it('keeps layer identity and hit mapping behind the scene seam', () => {
|
it('keeps layer identity and hit mapping behind the scene seam', () => {
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { applyAvatarEyeDefaults, type AvatarEyeDefaults } from './avatars'
|
import { applyAvatarEyeDefaults, type AvatarEyeDefaults } from '../avatar/avatars'
|
||||||
import { type Expression } from './geometry'
|
import { type Expression } from '../avatar/geometry'
|
||||||
|
|
||||||
export type CanvasPreviewTarget = 'head' | 'eyes'
|
export type CanvasPreviewTarget = 'head' | 'eyes'
|
||||||
|
|
||||||
933
src/features/rendering/components/AvatarCanvas.tsx
Normal file
933
src/features/rendering/components/AvatarCanvas.tsx
Normal file
@ -0,0 +1,933 @@
|
|||||||
|
import { RotateCcw } from 'lucide-react'
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useStudioLanguage } from '@/i18n'
|
||||||
|
|
||||||
|
import { PlaybackIdentity } from '@/app/components/common'
|
||||||
|
import {
|
||||||
|
bounded,
|
||||||
|
poseWithAvatarEyes,
|
||||||
|
useEscapeToCancel,
|
||||||
|
type Highlight,
|
||||||
|
type PlaybackStatus,
|
||||||
|
} from '@/app/studio-utils'
|
||||||
|
import { applyAvatarEyeDefaults, type AvatarEyeDefaults } from '@/features/avatar/avatars'
|
||||||
|
import { type BodyNode } from '@/features/avatar/body'
|
||||||
|
import { scaleEye, updateEyeDimension } from '@/features/avatar/expressionEditing'
|
||||||
|
import {
|
||||||
|
poseFromExpression,
|
||||||
|
renderBodyNodeEditor,
|
||||||
|
renderEyeEditor,
|
||||||
|
rotateBodyNodeAroundLocalAxis,
|
||||||
|
rotateExpressionAroundAxis,
|
||||||
|
rotateExpressionAroundCamera,
|
||||||
|
rotateExpressionWithArcball,
|
||||||
|
rotationRing,
|
||||||
|
translateBodyNodeAlongLocalAxis,
|
||||||
|
translateBodyNodeInCameraPlane,
|
||||||
|
type AvatarPose,
|
||||||
|
type Expression,
|
||||||
|
type Point3,
|
||||||
|
} from '@/features/avatar/geometry'
|
||||||
|
import {
|
||||||
|
beginManipulation,
|
||||||
|
beginManipulationFromRenderedValue,
|
||||||
|
finishManipulation,
|
||||||
|
previewManipulation,
|
||||||
|
type ManipulationSession,
|
||||||
|
} from '@/features/avatar/manipulationSession'
|
||||||
|
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
||||||
|
import { type CanvasPreviewTarget } from '@/features/rendering/canvasPreview'
|
||||||
|
import { type RenderedRotationGizmo } from '@/features/rendering/renderedRotationGizmo'
|
||||||
|
import { findBodyNodePath, type RenderedScene } from '@/features/rendering/renderedScene'
|
||||||
|
export function RotationGizmo({
|
||||||
|
expression,
|
||||||
|
rendered,
|
||||||
|
onChange,
|
||||||
|
onActiveChange,
|
||||||
|
onReset,
|
||||||
|
}: {
|
||||||
|
expression: Expression
|
||||||
|
rendered: RenderedRotationGizmo
|
||||||
|
onChange: (next: Expression) => void
|
||||||
|
onActiveChange: (active: boolean) => void
|
||||||
|
onReset: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useStudioLanguage()
|
||||||
|
const drag = useRef<
|
||||||
|
| {
|
||||||
|
type: 'axis'
|
||||||
|
axis: 'x' | 'y' | 'z'
|
||||||
|
startPoint: readonly [number, number]
|
||||||
|
tangent: readonly [number, number]
|
||||||
|
expression: Expression
|
||||||
|
}
|
||||||
|
| { type: 'view'; startAngle: number; expression: Expression }
|
||||||
|
| null
|
||||||
|
>(null)
|
||||||
|
const pose = poseFromExpression(expression)
|
||||||
|
const rings = {
|
||||||
|
x: rotationRing(pose, 'x'),
|
||||||
|
y: rotationRing(pose, 'y'),
|
||||||
|
z: rotationRing(pose, 'z'),
|
||||||
|
}
|
||||||
|
const toLocal = (event: React.PointerEvent<SVGElement>): readonly [number, number] => {
|
||||||
|
const rectangle = event.currentTarget.ownerSVGElement!.getBoundingClientRect()
|
||||||
|
return [
|
||||||
|
((event.clientX - rectangle.left) / rectangle.width) * 86 - 43,
|
||||||
|
((event.clientY - rectangle.top) / rectangle.height) * 86 - 43,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const unitVector = (from: Point3, to: Point3): readonly [number, number] => {
|
||||||
|
const x = to[0] - from[0]
|
||||||
|
const y = to[1] - from[1]
|
||||||
|
const length = Math.hypot(x, y) || 1
|
||||||
|
return [x / length, y / length]
|
||||||
|
}
|
||||||
|
const startAxis = (axis: 'x' | 'y' | 'z', event: React.PointerEvent<SVGElement>) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
onActiveChange(true)
|
||||||
|
const point = toLocal(event)
|
||||||
|
const ring = rings[axis]
|
||||||
|
let closestIndex = 0
|
||||||
|
let closestDistance = Number.POSITIVE_INFINITY
|
||||||
|
ring.slice(0, -1).forEach((ringPoint, index) => {
|
||||||
|
const distance = Math.hypot(ringPoint[0] - point[0], ringPoint[1] - point[1])
|
||||||
|
if (distance < closestDistance) {
|
||||||
|
closestIndex = index
|
||||||
|
closestDistance = distance
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const previous = ring[(closestIndex - 1 + ring.length - 1) % (ring.length - 1)]
|
||||||
|
const next = ring[(closestIndex + 1) % (ring.length - 1)]
|
||||||
|
drag.current = {
|
||||||
|
type: 'axis',
|
||||||
|
axis,
|
||||||
|
startPoint: point,
|
||||||
|
tangent: unitVector(previous, next),
|
||||||
|
expression,
|
||||||
|
}
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
const startView = (event: React.PointerEvent<SVGElement>) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
onActiveChange(true)
|
||||||
|
const point = toLocal(event)
|
||||||
|
drag.current = { type: 'view', startAngle: Math.atan2(point[1], point[0]), expression }
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
const move = (event: React.PointerEvent<SVGElement>) => {
|
||||||
|
if (!drag.current) return
|
||||||
|
const point = toLocal(event)
|
||||||
|
if (drag.current.type === 'view') {
|
||||||
|
const currentAngle = Math.atan2(point[1], point[0])
|
||||||
|
const delta = Math.atan2(
|
||||||
|
Math.sin(currentAngle - drag.current.startAngle),
|
||||||
|
Math.cos(currentAngle - drag.current.startAngle)
|
||||||
|
)
|
||||||
|
onChange(rotateExpressionAroundCamera(drag.current.expression, delta))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const signedDistance =
|
||||||
|
(point[0] - drag.current.startPoint[0]) * drag.current.tangent[0] +
|
||||||
|
(point[1] - drag.current.startPoint[1]) * drag.current.tangent[1]
|
||||||
|
onChange(
|
||||||
|
rotateExpressionAroundAxis(drag.current.expression, drag.current.axis, signedDistance * 1.5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const stop = () => {
|
||||||
|
drag.current = null
|
||||||
|
onActiveChange(false)
|
||||||
|
}
|
||||||
|
const cancel = () => {
|
||||||
|
if (drag.current) onChange(drag.current.expression)
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
useEscapeToCancel(cancel)
|
||||||
|
return (
|
||||||
|
<div className="gizmo-cluster">
|
||||||
|
<svg className="gizmo" viewBox="-43 -43 86 86" aria-label={t('Gizmo de rotation')}>
|
||||||
|
<circle
|
||||||
|
className="gizmo-orbit gizmo-camera"
|
||||||
|
cx="0"
|
||||||
|
cy="0"
|
||||||
|
r="38"
|
||||||
|
onPointerDown={startView}
|
||||||
|
onPointerMove={move}
|
||||||
|
onPointerUp={stop}
|
||||||
|
onPointerCancel={cancel}
|
||||||
|
/>
|
||||||
|
<motion.path
|
||||||
|
className="gizmo-orbit gizmo-y"
|
||||||
|
d={rendered.yPath}
|
||||||
|
onPointerDown={event => startAxis('y', event)}
|
||||||
|
onPointerMove={move}
|
||||||
|
onPointerUp={stop}
|
||||||
|
onPointerCancel={cancel}
|
||||||
|
/>
|
||||||
|
<motion.path
|
||||||
|
className="gizmo-orbit gizmo-x"
|
||||||
|
d={rendered.xPath}
|
||||||
|
onPointerDown={event => startAxis('x', event)}
|
||||||
|
onPointerMove={move}
|
||||||
|
onPointerUp={stop}
|
||||||
|
onPointerCancel={cancel}
|
||||||
|
/>
|
||||||
|
<motion.path
|
||||||
|
className="gizmo-orbit gizmo-z"
|
||||||
|
d={rendered.zPath}
|
||||||
|
onPointerDown={event => startAxis('z', event)}
|
||||||
|
onPointerMove={move}
|
||||||
|
onPointerUp={stop}
|
||||||
|
onPointerCancel={cancel}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<Button
|
||||||
|
className="gizmo-reset"
|
||||||
|
variant="secondary"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={t('Réinitialiser la rotation de la tête')}
|
||||||
|
onClick={onReset}
|
||||||
|
>
|
||||||
|
<RotateCcw />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransformAxis = 'x' | 'y' | 'z'
|
||||||
|
|
||||||
|
export function BodyNodeGizmo({
|
||||||
|
svgRef,
|
||||||
|
pose,
|
||||||
|
node,
|
||||||
|
onPreview,
|
||||||
|
onCommit,
|
||||||
|
}: {
|
||||||
|
svgRef: React.RefObject<SVGSVGElement | null>
|
||||||
|
pose: AvatarPose
|
||||||
|
node: BodyNode
|
||||||
|
onPreview: (next: BodyNode) => void
|
||||||
|
onCommit: (next: BodyNode) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useStudioLanguage()
|
||||||
|
const geometry = renderBodyNodeEditor(pose, node)
|
||||||
|
const [activeControl, setActiveControl] = useState<
|
||||||
|
{ mode: 'translate' | 'rotate'; axis: TransformAxis } | { mode: 'plane' } | undefined
|
||||||
|
>(undefined)
|
||||||
|
const drag = useRef<
|
||||||
|
| {
|
||||||
|
mode: 'translate' | 'rotate'
|
||||||
|
axis: TransformAxis
|
||||||
|
startPoint: readonly [number, number]
|
||||||
|
direction: readonly [number, number]
|
||||||
|
scale: number
|
||||||
|
node: BodyNode
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
mode: 'plane'
|
||||||
|
startPoint: readonly [number, number]
|
||||||
|
node: BodyNode
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
>(undefined)
|
||||||
|
const latestNode = useRef(node)
|
||||||
|
const manipulation = useRef<ManipulationSession<BodyNode> | null>(null)
|
||||||
|
const previewFrame = useRef<number | undefined>(undefined)
|
||||||
|
const axes: TransformAxis[] = ['x', 'y', 'z']
|
||||||
|
const toSvg = (event: React.PointerEvent<SVGElement>): readonly [number, number] => {
|
||||||
|
const rectangle = svgRef.current!.getBoundingClientRect()
|
||||||
|
return [
|
||||||
|
((event.clientX - rectangle.left) / rectangle.width) * 300 - 150,
|
||||||
|
((event.clientY - rectangle.top) / rectangle.height) * 300 - 150,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const directionBetween = (from: Point3, to: Point3): readonly [number, number] => {
|
||||||
|
const x = to[0] - from[0]
|
||||||
|
const y = to[1] - from[1]
|
||||||
|
const length = Math.hypot(x, y) || 1
|
||||||
|
return [x / length, y / length]
|
||||||
|
}
|
||||||
|
const ringPath = (points: Point3[]) =>
|
||||||
|
`M${points.map(point => `${point[0]} ${point[1]}`).join('L')}Z`
|
||||||
|
const startTranslate = (axis: TransformAxis, event: React.PointerEvent<SVGElement>) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
const endpoint = geometry.axes[axis]
|
||||||
|
const length = Math.max(
|
||||||
|
Math.hypot(endpoint[0] - geometry.center[0], endpoint[1] - geometry.center[1]),
|
||||||
|
1
|
||||||
|
)
|
||||||
|
drag.current = {
|
||||||
|
mode: 'translate',
|
||||||
|
axis,
|
||||||
|
startPoint: toSvg(event),
|
||||||
|
direction: directionBetween(geometry.center, endpoint),
|
||||||
|
scale: 34 / length,
|
||||||
|
node,
|
||||||
|
}
|
||||||
|
setActiveControl({ mode: 'translate', axis })
|
||||||
|
latestNode.current = node
|
||||||
|
manipulation.current = beginManipulation(node)
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
const startRotate = (axis: TransformAxis, event: React.PointerEvent<SVGElement>) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
const point = toSvg(event)
|
||||||
|
const ring = geometry.rings[axis]
|
||||||
|
let closestIndex = 0
|
||||||
|
let closestDistance = Infinity
|
||||||
|
ring.slice(0, -1).forEach((ringPoint, index) => {
|
||||||
|
const distance = Math.hypot(ringPoint[0] - point[0], ringPoint[1] - point[1])
|
||||||
|
if (distance < closestDistance) {
|
||||||
|
closestIndex = index
|
||||||
|
closestDistance = distance
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const previous = ring[(closestIndex - 1 + ring.length - 1) % (ring.length - 1)]
|
||||||
|
const next = ring[(closestIndex + 1) % (ring.length - 1)]
|
||||||
|
const radius = Math.max(
|
||||||
|
Math.hypot(
|
||||||
|
ring[closestIndex][0] - geometry.center[0],
|
||||||
|
ring[closestIndex][1] - geometry.center[1]
|
||||||
|
),
|
||||||
|
8
|
||||||
|
)
|
||||||
|
drag.current = {
|
||||||
|
mode: 'rotate',
|
||||||
|
axis,
|
||||||
|
startPoint: point,
|
||||||
|
direction: directionBetween(previous, next),
|
||||||
|
scale: 180 / Math.PI / radius,
|
||||||
|
node,
|
||||||
|
}
|
||||||
|
setActiveControl({ mode: 'rotate', axis })
|
||||||
|
latestNode.current = node
|
||||||
|
manipulation.current = beginManipulation(node)
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
const startPlaneTranslate = (event: React.PointerEvent<SVGElement>) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
drag.current = { mode: 'plane', startPoint: toSvg(event), node }
|
||||||
|
setActiveControl({ mode: 'plane' })
|
||||||
|
latestNode.current = node
|
||||||
|
manipulation.current = beginManipulation(node)
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
const move = (event: React.PointerEvent<SVGElement>) => {
|
||||||
|
if (!drag.current) return
|
||||||
|
const interaction = drag.current
|
||||||
|
const point = toSvg(event)
|
||||||
|
const deltaX = point[0] - interaction.startPoint[0]
|
||||||
|
const deltaY = point[1] - interaction.startPoint[1]
|
||||||
|
const next = (() => {
|
||||||
|
if (interaction.mode === 'plane') {
|
||||||
|
return translateBodyNodeInCameraPlane(interaction.node, pose, deltaX, deltaY)
|
||||||
|
}
|
||||||
|
const delta =
|
||||||
|
(deltaX * interaction.direction[0] + deltaY * interaction.direction[1]) * interaction.scale
|
||||||
|
return interaction.mode === 'translate'
|
||||||
|
? translateBodyNodeAlongLocalAxis(interaction.node, interaction.axis, delta)
|
||||||
|
: rotateBodyNodeAroundLocalAxis(interaction.node, interaction.axis, delta)
|
||||||
|
})()
|
||||||
|
latestNode.current = next
|
||||||
|
if (manipulation.current) {
|
||||||
|
previewManipulation(manipulation.current, next)
|
||||||
|
}
|
||||||
|
if (previewFrame.current !== undefined) return
|
||||||
|
previewFrame.current = requestAnimationFrame(() => {
|
||||||
|
previewFrame.current = undefined
|
||||||
|
onPreview(latestNode.current)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const stop = () => {
|
||||||
|
if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current)
|
||||||
|
previewFrame.current = undefined
|
||||||
|
if (manipulation.current) {
|
||||||
|
finishManipulation(manipulation.current, 'commit', { preview: onPreview, commit: onCommit })
|
||||||
|
}
|
||||||
|
manipulation.current = null
|
||||||
|
drag.current = undefined
|
||||||
|
setActiveControl(undefined)
|
||||||
|
}
|
||||||
|
const cancel = () => {
|
||||||
|
if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current)
|
||||||
|
previewFrame.current = undefined
|
||||||
|
if (manipulation.current) {
|
||||||
|
finishManipulation(manipulation.current, 'cancel', { preview: onPreview, commit: onCommit })
|
||||||
|
}
|
||||||
|
manipulation.current = null
|
||||||
|
drag.current = undefined
|
||||||
|
setActiveControl(undefined)
|
||||||
|
}
|
||||||
|
useEscapeToCancel(cancel)
|
||||||
|
const resetTransform = (event: React.MouseEvent<SVGGElement>) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
const resetNode: BodyNode = { ...node, position: [0, 0, 0], rotation: [0, 0, 0] }
|
||||||
|
latestNode.current = resetNode
|
||||||
|
onCommit(resetNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g className="body-node-gizmo" aria-label={`${t('Transformer')} ${t(node.name)}`}>
|
||||||
|
{axes.map(axis => (
|
||||||
|
<g key={`ring-${axis}`}>
|
||||||
|
<path
|
||||||
|
className="body-gizmo-hitbox body-gizmo-ring-hitbox"
|
||||||
|
d={ringPath(geometry.rings[axis])}
|
||||||
|
onPointerDown={event => startRotate(axis, event)}
|
||||||
|
onPointerMove={move}
|
||||||
|
onPointerUp={stop}
|
||||||
|
onPointerCancel={cancel}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className={`body-gizmo-ring gizmo-${axis}${activeControl?.mode === 'rotate' && activeControl.axis === axis ? ' is-active' : ''}`}
|
||||||
|
d={ringPath(geometry.rings[axis])}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{axes.map(axis => (
|
||||||
|
<g key={`axis-${axis}`}>
|
||||||
|
<path
|
||||||
|
className="body-gizmo-hitbox body-gizmo-axis-hitbox"
|
||||||
|
d={`M${geometry.center[0]} ${geometry.center[1]}L${geometry.axes[axis][0]} ${geometry.axes[axis][1]}`}
|
||||||
|
onPointerDown={event => startTranslate(axis, event)}
|
||||||
|
onPointerMove={move}
|
||||||
|
onPointerUp={stop}
|
||||||
|
onPointerCancel={cancel}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className={`body-gizmo-axis gizmo-${axis}${activeControl?.mode === 'translate' && activeControl.axis === axis ? ' is-active' : ''}`}
|
||||||
|
d={`M${geometry.center[0]} ${geometry.center[1]}L${geometry.axes[axis][0]} ${geometry.axes[axis][1]}`}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className={`body-gizmo-handle gizmo-${axis}`}
|
||||||
|
cx={geometry.axes[axis][0]}
|
||||||
|
cy={geometry.axes[axis][1]}
|
||||||
|
r="3.5"
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
className={`body-gizmo-label body-gizmo-label-${axis}`}
|
||||||
|
x={geometry.axes[axis][0]}
|
||||||
|
y={geometry.axes[axis][1] - 5}
|
||||||
|
>
|
||||||
|
{axis.toUpperCase()}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
<circle
|
||||||
|
className="body-gizmo-plane-hitbox"
|
||||||
|
cx={geometry.center[0]}
|
||||||
|
cy={geometry.center[1]}
|
||||||
|
r="11"
|
||||||
|
aria-label={t('Déplacer dans le plan de la caméra')}
|
||||||
|
onPointerDown={startPlaneTranslate}
|
||||||
|
onPointerMove={move}
|
||||||
|
onPointerUp={stop}
|
||||||
|
onPointerCancel={cancel}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className={`body-gizmo-origin${activeControl?.mode === 'plane' ? ' is-active' : ''}`}
|
||||||
|
cx={geometry.center[0]}
|
||||||
|
cy={geometry.center[1]}
|
||||||
|
r="4"
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
<g
|
||||||
|
className="body-gizmo-reset"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={t(`Réinitialiser la position et la rotation de ${node.name}`)}
|
||||||
|
transform={`translate(${bounded(geometry.center[0] + 32, -140, 140)} ${bounded(geometry.center[1] - 32, -140, 140)})`}
|
||||||
|
onClick={resetTransform}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||||
|
event.preventDefault()
|
||||||
|
const resetNode: BodyNode = { ...node, position: [0, 0, 0], rotation: [0, 0, 0] }
|
||||||
|
latestNode.current = resetNode
|
||||||
|
onCommit(resetNode)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<circle r="6.5" />
|
||||||
|
<text y="2.5">↺</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AvatarCanvas({
|
||||||
|
expression,
|
||||||
|
avatarEyes,
|
||||||
|
surface,
|
||||||
|
scene,
|
||||||
|
rotationGizmo,
|
||||||
|
showWire,
|
||||||
|
bodyEditing,
|
||||||
|
selectedBodyNodeId,
|
||||||
|
selectedBodyNode,
|
||||||
|
selectedSide,
|
||||||
|
linked,
|
||||||
|
highlight,
|
||||||
|
onHighlightChange,
|
||||||
|
onBodyNodeSelect,
|
||||||
|
onBodyNodePreview,
|
||||||
|
onBodyNodeChange,
|
||||||
|
onEyeSelect,
|
||||||
|
onPreview,
|
||||||
|
onChange,
|
||||||
|
onReset,
|
||||||
|
onEyeChange,
|
||||||
|
playback,
|
||||||
|
onManipulationStart,
|
||||||
|
}: {
|
||||||
|
expression: Expression
|
||||||
|
avatarEyes: AvatarEyeDefaults
|
||||||
|
surface: SurfaceConfig
|
||||||
|
scene: RenderedScene
|
||||||
|
rotationGizmo: RenderedRotationGizmo
|
||||||
|
showWire: boolean
|
||||||
|
bodyEditing: boolean
|
||||||
|
selectedBodyNodeId: 'primary' | string | null
|
||||||
|
selectedBodyNode: BodyNode | null
|
||||||
|
selectedSide: -1 | 1 | null
|
||||||
|
linked: { width: boolean; height: boolean; size: boolean }
|
||||||
|
highlight: Highlight
|
||||||
|
onHighlightChange: (highlight: Highlight) => void
|
||||||
|
onBodyNodeSelect: (id: 'primary' | string | null) => void
|
||||||
|
onBodyNodePreview: (next: BodyNode) => void
|
||||||
|
onBodyNodeChange: (next: BodyNode) => void
|
||||||
|
onEyeSelect: (side: -1 | 1) => void
|
||||||
|
onPreview: (next: Expression, target: CanvasPreviewTarget) => void
|
||||||
|
onChange: (next: Expression) => void
|
||||||
|
onReset: (next: Expression) => void
|
||||||
|
onEyeChange?: (next: Expression) => void
|
||||||
|
playback: { name: string; status: Exclude<PlaybackStatus, 'stopped'> } | null
|
||||||
|
onManipulationStart: () => Expression
|
||||||
|
}) {
|
||||||
|
const { t } = useStudioLanguage()
|
||||||
|
const {
|
||||||
|
wirePaths,
|
||||||
|
backPaths,
|
||||||
|
frontPaths,
|
||||||
|
backNodeIds,
|
||||||
|
frontNodeIds,
|
||||||
|
headPath,
|
||||||
|
leftPath,
|
||||||
|
rightPath,
|
||||||
|
leftOpacity,
|
||||||
|
rightOpacity,
|
||||||
|
offsetX,
|
||||||
|
offsetY,
|
||||||
|
} = scene
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
|
const [activeDragType, setActiveDragType] = useState<
|
||||||
|
'arcball' | 'width' | 'height' | 'size' | 'spacing' | 'rotate' | null
|
||||||
|
>(null)
|
||||||
|
const drag = useRef<
|
||||||
|
| {
|
||||||
|
type: 'arcball'
|
||||||
|
startPoint: readonly [number, number]
|
||||||
|
expression: Expression
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'width' | 'height' | 'size' | 'spacing' | 'rotate'
|
||||||
|
side: -1 | 1
|
||||||
|
startPoint: readonly [number, number]
|
||||||
|
expression: Expression
|
||||||
|
center: Point3
|
||||||
|
widthAxis: readonly [number, number]
|
||||||
|
heightAxis: readonly [number, number]
|
||||||
|
spacingAxis: readonly [number, number]
|
||||||
|
startPointerAngle: number
|
||||||
|
startDistance: number
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
>(null)
|
||||||
|
const canvasManipulation = useRef<ManipulationSession<Expression> | null>(null)
|
||||||
|
const editor =
|
||||||
|
selectedSide === null
|
||||||
|
? null
|
||||||
|
: renderEyeEditor(poseWithAvatarEyes(expression, avatarEyes), surface, selectedSide)
|
||||||
|
const selectedBodyPath = (() => {
|
||||||
|
if (!bodyEditing || !selectedBodyNodeId) return null
|
||||||
|
return findBodyNodePath(scene, selectedBodyNodeId)
|
||||||
|
})()
|
||||||
|
|
||||||
|
const toSvg = (event: React.PointerEvent<SVGElement>): readonly [number, number] => {
|
||||||
|
const rectangle = svgRef.current!.getBoundingClientRect()
|
||||||
|
return [
|
||||||
|
((event.clientX - rectangle.left) / rectangle.width) * 300 - 150,
|
||||||
|
((event.clientY - rectangle.top) / rectangle.height) * 300 - 150,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const unitVector = (from: Point3, to: Point3): readonly [number, number] => {
|
||||||
|
const x = to[0] - from[0]
|
||||||
|
const y = to[1] - from[1]
|
||||||
|
const length = Math.hypot(x, y) || 1
|
||||||
|
return [x / length, y / length]
|
||||||
|
}
|
||||||
|
const startDrag = (event: React.PointerEvent<SVGElement>) => {
|
||||||
|
const session = beginManipulationFromRenderedValue(onManipulationStart)
|
||||||
|
const renderedExpression = session.initial
|
||||||
|
onBodyNodeSelect('primary')
|
||||||
|
onHighlightChange('head')
|
||||||
|
drag.current = {
|
||||||
|
type: 'arcball',
|
||||||
|
startPoint: toSvg(event),
|
||||||
|
expression: renderedExpression,
|
||||||
|
}
|
||||||
|
canvasManipulation.current = session
|
||||||
|
setActiveDragType('arcball')
|
||||||
|
svgRef.current!.setPointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
const selectBodyPath = (
|
||||||
|
event: React.PointerEvent<SVGPathElement>,
|
||||||
|
nodeId: string | null | undefined
|
||||||
|
) => {
|
||||||
|
if (!nodeId || !bodyEditing) {
|
||||||
|
startDrag(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.stopPropagation()
|
||||||
|
onBodyNodeSelect(nodeId)
|
||||||
|
}
|
||||||
|
const selectEye = (side: -1 | 1, event: React.PointerEvent<SVGPathElement>) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
onBodyNodeSelect(null)
|
||||||
|
onEyeSelect(side)
|
||||||
|
}
|
||||||
|
const startHandle = (
|
||||||
|
type: 'width' | 'height' | 'size' | 'spacing' | 'rotate',
|
||||||
|
event: React.PointerEvent<SVGElement>
|
||||||
|
) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (selectedSide === null || !editor) return
|
||||||
|
const session = beginManipulationFromRenderedValue(onManipulationStart)
|
||||||
|
const renderedExpression = session.initial
|
||||||
|
onHighlightChange(selectedSide < 0 ? 'left' : 'right')
|
||||||
|
const point = toSvg(event)
|
||||||
|
const editableExpression = bodyEditing
|
||||||
|
? applyAvatarEyeDefaults(renderedExpression, avatarEyes)
|
||||||
|
: renderedExpression
|
||||||
|
const livePose = poseWithAvatarEyes(renderedExpression, avatarEyes)
|
||||||
|
const liveEditor = renderEyeEditor(livePose, surface, selectedSide)
|
||||||
|
const leftEditor = renderEyeEditor(livePose, surface, -1)
|
||||||
|
const rightEditor = renderEyeEditor(livePose, surface, 1)
|
||||||
|
drag.current = {
|
||||||
|
type,
|
||||||
|
side: selectedSide,
|
||||||
|
startPoint: point,
|
||||||
|
expression: editableExpression,
|
||||||
|
center: liveEditor.center,
|
||||||
|
widthAxis: unitVector(liveEditor.center, liveEditor.widthHandle),
|
||||||
|
heightAxis: unitVector(liveEditor.center, liveEditor.heightHandle),
|
||||||
|
spacingAxis: unitVector(leftEditor.center, rightEditor.center),
|
||||||
|
startPointerAngle: Math.atan2(
|
||||||
|
point[1] - liveEditor.center[1],
|
||||||
|
point[0] - liveEditor.center[0]
|
||||||
|
),
|
||||||
|
startDistance: Math.max(
|
||||||
|
Math.hypot(point[0] - liveEditor.center[0], point[1] - liveEditor.center[1]),
|
||||||
|
1
|
||||||
|
),
|
||||||
|
}
|
||||||
|
canvasManipulation.current = beginManipulation(editableExpression)
|
||||||
|
setActiveDragType(type)
|
||||||
|
svgRef.current!.setPointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
const move = (event: React.PointerEvent<SVGSVGElement>) => {
|
||||||
|
if (!drag.current) return
|
||||||
|
const point = toSvg(event)
|
||||||
|
if (drag.current.type === 'arcball') {
|
||||||
|
const next = rotateExpressionWithArcball(
|
||||||
|
drag.current.expression,
|
||||||
|
drag.current.startPoint,
|
||||||
|
point
|
||||||
|
)
|
||||||
|
if (canvasManipulation.current) {
|
||||||
|
previewManipulation(canvasManipulation.current, next, value => onPreview(value, 'head'))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const interaction = drag.current
|
||||||
|
const suffix = interaction.side < 0 ? 'Left' : 'Right'
|
||||||
|
const deltaX = point[0] - interaction.startPoint[0]
|
||||||
|
const deltaY = point[1] - interaction.startPoint[1]
|
||||||
|
const along = (axis: readonly [number, number]) => deltaX * axis[0] + deltaY * axis[1]
|
||||||
|
let next = { ...interaction.expression }
|
||||||
|
const side = interaction.side < 0 ? 'Left' : 'Right'
|
||||||
|
if (interaction.type === 'width') {
|
||||||
|
const value = bounded(
|
||||||
|
interaction.expression[`width${suffix}`] + along(interaction.widthAxis) * 2,
|
||||||
|
10,
|
||||||
|
100
|
||||||
|
)
|
||||||
|
next = updateEyeDimension(interaction.expression, side, 'width', value, linked.width)
|
||||||
|
} else if (interaction.type === 'height') {
|
||||||
|
const value = bounded(
|
||||||
|
interaction.expression[`height${suffix}`] + along(interaction.heightAxis) * 2,
|
||||||
|
10,
|
||||||
|
100
|
||||||
|
)
|
||||||
|
next = updateEyeDimension(interaction.expression, side, 'height', value, linked.height)
|
||||||
|
} else if (interaction.type === 'size') {
|
||||||
|
const distance = Math.hypot(
|
||||||
|
point[0] - interaction.center[0],
|
||||||
|
point[1] - interaction.center[1]
|
||||||
|
)
|
||||||
|
const factor = distance / interaction.startDistance
|
||||||
|
const targetSize =
|
||||||
|
Math.max(
|
||||||
|
interaction.expression[`width${suffix}`],
|
||||||
|
interaction.expression[`height${suffix}`]
|
||||||
|
) * factor
|
||||||
|
next = scaleEye(interaction.expression, side, targetSize, linked.size)
|
||||||
|
} else if (interaction.type === 'spacing') {
|
||||||
|
const startSpacing = interaction.expression.spacing
|
||||||
|
const spacing = bounded(startSpacing + along(interaction.spacingAxis), 0, 150)
|
||||||
|
next.spacing = spacing
|
||||||
|
} else {
|
||||||
|
const currentAngle = Math.atan2(
|
||||||
|
point[1] - interaction.center[1],
|
||||||
|
point[0] - interaction.center[0]
|
||||||
|
)
|
||||||
|
const deltaAngle = Math.atan2(
|
||||||
|
Math.sin(currentAngle - interaction.startPointerAngle),
|
||||||
|
Math.cos(currentAngle - interaction.startPointerAngle)
|
||||||
|
)
|
||||||
|
next[interaction.side < 0 ? 'leftAngle' : 'rightAngle'] =
|
||||||
|
interaction.expression[interaction.side < 0 ? 'leftAngle' : 'rightAngle'] +
|
||||||
|
(deltaAngle * 180) / Math.PI
|
||||||
|
}
|
||||||
|
if (canvasManipulation.current) {
|
||||||
|
previewManipulation(canvasManipulation.current, next, value => onPreview(value, 'eyes'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const commitDrag = () => {
|
||||||
|
const interaction = drag.current
|
||||||
|
const session = canvasManipulation.current
|
||||||
|
if (interaction && session) {
|
||||||
|
finishManipulation(session, 'commit', {
|
||||||
|
preview: value => onPreview(value, interaction.type === 'arcball' ? 'head' : 'eyes'),
|
||||||
|
commit: interaction.type === 'arcball' ? onChange : (onEyeChange ?? onChange),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
canvasManipulation.current = null
|
||||||
|
drag.current = null
|
||||||
|
setActiveDragType(null)
|
||||||
|
onHighlightChange(null)
|
||||||
|
}
|
||||||
|
const cancelDrag = () => {
|
||||||
|
const interaction = drag.current
|
||||||
|
const session = canvasManipulation.current
|
||||||
|
if (interaction && session) {
|
||||||
|
finishManipulation(session, 'cancel', {
|
||||||
|
preview: value => onPreview(value, interaction.type === 'arcball' ? 'head' : 'eyes'),
|
||||||
|
commit: onChange,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
canvasManipulation.current = null
|
||||||
|
drag.current = null
|
||||||
|
setActiveDragType(null)
|
||||||
|
onHighlightChange(null)
|
||||||
|
}
|
||||||
|
useEscapeToCancel(cancelDrag)
|
||||||
|
return (
|
||||||
|
<div className="avatar-wrap">
|
||||||
|
{playback && (
|
||||||
|
<motion.div
|
||||||
|
className="stage-playback-status"
|
||||||
|
initial={{ opacity: 0, scale: 0.96, y: -4 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
transition={{ type: 'spring', duration: 0.3, bounce: 0 }}
|
||||||
|
>
|
||||||
|
<PlaybackIdentity name={playback.name} status={playback.status} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
className="avatar"
|
||||||
|
viewBox="-150 -150 300 300"
|
||||||
|
role="img"
|
||||||
|
aria-label={t('Avatar procédural')}
|
||||||
|
onPointerMove={move}
|
||||||
|
onPointerUp={commitDrag}
|
||||||
|
onPointerCancel={cancelDrag}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="avatar-head-clip">
|
||||||
|
<motion.path d={headPath} />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<motion.g style={{ x: offsetX, y: offsetY }}>
|
||||||
|
{backPaths.map((pathValue, index) => (
|
||||||
|
<motion.path
|
||||||
|
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
|
||||||
|
d={pathValue}
|
||||||
|
key={index}
|
||||||
|
onPointerDown={event => selectBodyPath(event, backNodeIds.current[index])}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<motion.path
|
||||||
|
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
|
||||||
|
d={headPath}
|
||||||
|
onPointerDown={event => {
|
||||||
|
onBodyNodeSelect('primary')
|
||||||
|
startDrag(event)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<g clipPath="url(#avatar-head-clip)">
|
||||||
|
{(showWire || highlight === 'head') &&
|
||||||
|
wirePaths.map((pathValue, index) => (
|
||||||
|
<motion.path className="wire" d={pathValue} key={index} />
|
||||||
|
))}
|
||||||
|
<motion.path
|
||||||
|
className={`avatar-eye ${selectedSide === -1 || highlight === 'left' || highlight === 'both' ? 'cyan-outline' : ''}`}
|
||||||
|
d={leftPath}
|
||||||
|
opacity={leftOpacity}
|
||||||
|
onPointerDown={event => selectEye(-1, event)}
|
||||||
|
/>
|
||||||
|
<motion.path
|
||||||
|
className={`avatar-eye ${selectedSide === 1 || highlight === 'right' || highlight === 'both' ? 'cyan-outline' : ''}`}
|
||||||
|
d={rightPath}
|
||||||
|
opacity={rightOpacity}
|
||||||
|
onPointerDown={event => selectEye(1, event)}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
{frontPaths.map((pathValue, index) => (
|
||||||
|
<motion.path
|
||||||
|
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
|
||||||
|
d={pathValue}
|
||||||
|
key={index}
|
||||||
|
onPointerDown={event => selectBodyPath(event, frontNodeIds.current[index])}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</motion.g>
|
||||||
|
{selectedBodyPath && (
|
||||||
|
<motion.path className="selection-outline body-selection-outline" d={selectedBodyPath} />
|
||||||
|
)}
|
||||||
|
{bodyEditing && selectedBodyNode && (
|
||||||
|
<BodyNodeGizmo
|
||||||
|
svgRef={svgRef}
|
||||||
|
pose={poseWithAvatarEyes(expression, avatarEyes)}
|
||||||
|
node={selectedBodyNode}
|
||||||
|
onPreview={onBodyNodePreview}
|
||||||
|
onCommit={onBodyNodeChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editor?.visible && (
|
||||||
|
<g className="eye-editor">
|
||||||
|
{activeDragType !== null && activeDragType !== 'arcball' && (
|
||||||
|
<path className="selection-outline" d={editor.selectionPath} />
|
||||||
|
)}
|
||||||
|
<path className="editor-guide" d={editor.widthGuide} />
|
||||||
|
<path className="editor-guide" d={editor.heightGuide} />
|
||||||
|
<path className="editor-guide" d={editor.rotationGuide} />
|
||||||
|
<path className="editor-guide" d={editor.spacingGuide} />
|
||||||
|
<EditorCircle point={editor.widthHandle} label="L" type="width" onStart={startHandle} />
|
||||||
|
<EditorCircle
|
||||||
|
point={editor.heightHandle}
|
||||||
|
label="H"
|
||||||
|
type="height"
|
||||||
|
onStart={startHandle}
|
||||||
|
/>
|
||||||
|
<EditorCircle
|
||||||
|
point={editor.rotateHandle}
|
||||||
|
label="R"
|
||||||
|
type="rotate"
|
||||||
|
onStart={startHandle}
|
||||||
|
/>
|
||||||
|
<EditorSquare point={editor.sizeHandle} label="S" type="size" onStart={startHandle} />
|
||||||
|
<EditorSquare
|
||||||
|
point={editor.spacingHandle}
|
||||||
|
label="E"
|
||||||
|
type="spacing"
|
||||||
|
onStart={startHandle}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
<RotationGizmo
|
||||||
|
expression={expression}
|
||||||
|
rendered={rotationGizmo}
|
||||||
|
onChange={onChange}
|
||||||
|
onActiveChange={active => onHighlightChange(active ? 'head' : null)}
|
||||||
|
onReset={() => onReset({ ...expression, headX: 0, headY: 0, headZ: 0 })}
|
||||||
|
/>
|
||||||
|
<div className="axis-key">
|
||||||
|
<i className="x" />X <i className="y" />Y <i className="z" />Z
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EyeHandle = 'width' | 'height' | 'size' | 'spacing' | 'rotate'
|
||||||
|
type HandleStart = (type: EyeHandle, event: React.PointerEvent<SVGElement>) => void
|
||||||
|
|
||||||
|
export function EditorCircle({
|
||||||
|
point,
|
||||||
|
label,
|
||||||
|
type,
|
||||||
|
onStart,
|
||||||
|
}: {
|
||||||
|
point: Point3
|
||||||
|
label: string
|
||||||
|
type: EyeHandle
|
||||||
|
onStart: HandleStart
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
className="editor-control"
|
||||||
|
data-eye-handle={type}
|
||||||
|
onPointerDown={event => onStart(type, event)}
|
||||||
|
>
|
||||||
|
<circle className="editor-handle" cx={point[0]} cy={point[1]} r="5.5" />
|
||||||
|
<text className="editor-label" x={point[0]} y={point[1] + 2.6}>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorSquare({
|
||||||
|
point,
|
||||||
|
label,
|
||||||
|
type,
|
||||||
|
onStart,
|
||||||
|
}: {
|
||||||
|
point: Point3
|
||||||
|
label: string
|
||||||
|
type: EyeHandle
|
||||||
|
onStart: HandleStart
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
className="editor-control"
|
||||||
|
data-eye-handle={type}
|
||||||
|
onPointerDown={event => onStart(type, event)}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
className="editor-handle"
|
||||||
|
x={point[0] - 5}
|
||||||
|
y={point[1] - 5}
|
||||||
|
width="10"
|
||||||
|
height="10"
|
||||||
|
rx="2"
|
||||||
|
/>
|
||||||
|
<text className="editor-label" x={point[0]} y={point[1] + 2.6}>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { motionValue, type MotionValue } from 'motion'
|
import { motionValue, type MotionValue } from 'motion'
|
||||||
|
|
||||||
import { poseFromExpression, rotationRing, type Expression, type Point3 } from './geometry'
|
import { poseFromExpression, rotationRing, type Expression, type Point3 } from '../avatar/geometry'
|
||||||
|
|
||||||
export type RenderedRotationGizmo = {
|
export type RenderedRotationGizmo = {
|
||||||
xPath: MotionValue<string>
|
xPath: MotionValue<string>
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import { motionValue, type MotionValue } from 'motion'
|
import { motionValue, type MotionValue } from 'motion'
|
||||||
|
|
||||||
import type { AvatarColors } from './avatars'
|
import type { AvatarColors } from '../avatar/avatars'
|
||||||
import { MAX_BODY_NODES } from './body'
|
import { MAX_BODY_NODES } from '../avatar/body'
|
||||||
import type { AvatarGeometry } from './geometry'
|
import type { AvatarGeometry } from '../avatar/geometry'
|
||||||
|
|
||||||
export type RenderedScene = {
|
export type RenderedScene = {
|
||||||
headPath: MotionValue<string>
|
headPath: MotionValue<string>
|
||||||
@ -1,13 +1,13 @@
|
|||||||
import { createAvatar } from '../avatars'
|
import { createAvatar } from '@/features/avatar/avatars'
|
||||||
import { createInitialSequences } from '../sequences'
|
import { createInitialSequences } from '@/features/animation/sequences'
|
||||||
import { initialExpressions } from '../presets'
|
import { initialExpressions } from '@/features/avatar/presets'
|
||||||
import {
|
import {
|
||||||
createStudioDocumentStore,
|
createStudioDocumentStore,
|
||||||
loadStudioDocument,
|
loadStudioDocument,
|
||||||
parseImportedStudioDocument,
|
parseImportedStudioDocument,
|
||||||
serializeStudioDocument,
|
serializeStudioDocument,
|
||||||
type StudioDocument,
|
type StudioDocument,
|
||||||
} from '../studioDocument'
|
} from '@/features/studio/studioDocument'
|
||||||
|
|
||||||
const documentFixture = (): StudioDocument => {
|
const documentFixture = (): StudioDocument => {
|
||||||
const avatar = createAvatar('Strobi')
|
const avatar = createAvatar('Strobi')
|
||||||
129
src/features/studio/components/StudioDialogs.tsx
Normal file
129
src/features/studio/components/StudioDialogs.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
|
import type { StudioController } from '@/features/studio/useStudioController'
|
||||||
|
|
||||||
|
export function StudioDialogs({ controller }: { controller: StudioController }) {
|
||||||
|
const {
|
||||||
|
activeAvatar,
|
||||||
|
animationsAffectedByExpressionDeletion,
|
||||||
|
avatars,
|
||||||
|
confirmStudioProjectImport,
|
||||||
|
deleteActiveAvatar,
|
||||||
|
deleteAvatarOpen,
|
||||||
|
deleteEditing,
|
||||||
|
deleteExpressionOpen,
|
||||||
|
deleteSequenceEditing,
|
||||||
|
deleteSequenceOpen,
|
||||||
|
expression,
|
||||||
|
expressions,
|
||||||
|
pendingProjectImport,
|
||||||
|
setDeleteAvatarOpen,
|
||||||
|
setDeleteExpressionOpen,
|
||||||
|
setDeleteSequenceOpen,
|
||||||
|
setPendingProjectImport,
|
||||||
|
t,
|
||||||
|
} = controller
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AlertDialog open={deleteAvatarOpen} onOpenChange={setDeleteAvatarOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t(`Supprimer ${activeAvatar.name} ?`)}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t(
|
||||||
|
'Le corps, les expressions et les animations propres à cet avatar seront définitivement supprimés. La bibliothèque de base sera conservée.'
|
||||||
|
)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t('Annuler')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={deleteActiveAvatar}>{t('Supprimer')}</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
<AlertDialog open={deleteExpressionOpen} onOpenChange={setDeleteExpressionOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t('Supprimer cette expression ?')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t(
|
||||||
|
'Cette action retirera définitivement le preset de la bibliothèque de cet avatar.'
|
||||||
|
)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
{animationsAffectedByExpressionDeletion.length > 0 && (
|
||||||
|
<div className="mt-2 grid gap-2 rounded-lg border border-destructive/20 bg-destructive/5 p-3 text-sm">
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
{t('Cette expression sera aussi retirée des animations suivantes :')}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{animationsAffectedByExpressionDeletion.map(sequence => (
|
||||||
|
<Badge key={sequence.id} variant="outline">
|
||||||
|
{sequence.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
'Si une animation ne contient que cette expression, l’expression de repli lui sera assignée pour qu’elle reste jouable.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t('Annuler')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={deleteEditing}>{t('Supprimer')}</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
<AlertDialog open={deleteSequenceOpen} onOpenChange={setDeleteSequenceOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t('Supprimer cette animation ?')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t('Cette action supprimera définitivement cette animation.')}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t('Annuler')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={deleteSequenceEditing}>{t('Supprimer')}</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
<AlertDialog
|
||||||
|
open={pendingProjectImport !== null}
|
||||||
|
onOpenChange={open => {
|
||||||
|
if (!open) setPendingProjectImport(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t('Importer ce projet ?')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t(
|
||||||
|
'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.'
|
||||||
|
)}{' '}
|
||||||
|
{pendingProjectImport?.fileName}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t('Annuler')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmStudioProjectImport}>
|
||||||
|
{t('Importer')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
1977
src/features/studio/components/StudioInspector.tsx
Normal file
1977
src/features/studio/components/StudioInspector.tsx
Normal file
File diff suppressed because it is too large
Load Diff
168
src/features/studio/components/StudioStage.tsx
Normal file
168
src/features/studio/components/StudioStage.tsx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import { Camera, Info } from 'lucide-react'
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { type CSSProperties } from 'react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { type StudioLanguage } from '@/i18n'
|
||||||
|
|
||||||
|
import { AvatarCanvas } from '@/features/rendering/components/AvatarCanvas'
|
||||||
|
import type { StudioController } from '@/features/studio/useStudioController'
|
||||||
|
|
||||||
|
export function StudioStage({ controller }: { controller: StudioController }) {
|
||||||
|
const {
|
||||||
|
activeAvatarEyes,
|
||||||
|
activeSequenceLabel,
|
||||||
|
bodyEditing,
|
||||||
|
canvasExpression,
|
||||||
|
commitBodyNode,
|
||||||
|
editing,
|
||||||
|
expression,
|
||||||
|
freezeLivePreviewForManipulation,
|
||||||
|
highlight,
|
||||||
|
language,
|
||||||
|
linked,
|
||||||
|
mode,
|
||||||
|
persistEditedEyeExpression,
|
||||||
|
photoFlash,
|
||||||
|
playbackStatus,
|
||||||
|
previewCanvasExpression,
|
||||||
|
previewExpressionDraft,
|
||||||
|
previewSelectedBodyNode,
|
||||||
|
renderedColors,
|
||||||
|
renderedRotationGizmo,
|
||||||
|
renderedScene,
|
||||||
|
selectBodyNode,
|
||||||
|
selectedBodyNode,
|
||||||
|
selectedBodyNodeId,
|
||||||
|
selectedEyeSide,
|
||||||
|
setEditing,
|
||||||
|
setLanguage,
|
||||||
|
setSelectedEyeSide,
|
||||||
|
showWire,
|
||||||
|
snapshotFormat,
|
||||||
|
surface,
|
||||||
|
t,
|
||||||
|
takePicture,
|
||||||
|
transitionToExpression,
|
||||||
|
updateHighlight,
|
||||||
|
updateImmediate,
|
||||||
|
} = controller
|
||||||
|
return (
|
||||||
|
<motion.section
|
||||||
|
className="stage-column"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--avatar-body-color': renderedColors.body,
|
||||||
|
'--avatar-eye-color': renderedColors.eyes,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="brand">
|
||||||
|
<span className="brand-mark" />
|
||||||
|
Bible Strong <em>Avatar Lab</em>
|
||||||
|
</div>
|
||||||
|
<div className="language-picker">
|
||||||
|
<span aria-hidden="true">{language === 'en' ? '🇬🇧' : language === 'fr' ? '🇫🇷' : '🇨🇳'}</span>
|
||||||
|
<Select
|
||||||
|
value={language}
|
||||||
|
items={[
|
||||||
|
{ value: 'en', label: 'English' },
|
||||||
|
{ value: 'fr', label: 'Français' },
|
||||||
|
{ value: 'zh-CN', label: '中文' },
|
||||||
|
]}
|
||||||
|
onValueChange={next => next && setLanguage(next as StudioLanguage)}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label={t('Langue de l’interface')}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="en">English</SelectItem>
|
||||||
|
<SelectItem value="fr">Français</SelectItem>
|
||||||
|
<SelectItem value="zh-CN">中文</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<AvatarCanvas
|
||||||
|
expression={canvasExpression}
|
||||||
|
avatarEyes={activeAvatarEyes}
|
||||||
|
surface={surface}
|
||||||
|
scene={renderedScene}
|
||||||
|
rotationGizmo={renderedRotationGizmo}
|
||||||
|
showWire={showWire}
|
||||||
|
bodyEditing={bodyEditing}
|
||||||
|
selectedBodyNodeId={selectedBodyNodeId}
|
||||||
|
selectedBodyNode={selectedBodyNode}
|
||||||
|
selectedSide={selectedEyeSide}
|
||||||
|
linked={linked}
|
||||||
|
highlight={highlight}
|
||||||
|
onHighlightChange={updateHighlight}
|
||||||
|
onBodyNodeSelect={selectBodyNode}
|
||||||
|
onBodyNodePreview={previewSelectedBodyNode}
|
||||||
|
onBodyNodeChange={commitBodyNode}
|
||||||
|
onEyeSelect={setSelectedEyeSide}
|
||||||
|
onPreview={previewCanvasExpression}
|
||||||
|
onChange={editing ? previewExpressionDraft : updateImmediate}
|
||||||
|
onReset={next => {
|
||||||
|
if (editing) {
|
||||||
|
setEditing(current => (current ? { ...current, draft: next } : current))
|
||||||
|
}
|
||||||
|
transitionToExpression(next)
|
||||||
|
}}
|
||||||
|
onEyeChange={
|
||||||
|
editing ? previewExpressionDraft : bodyEditing ? persistEditedEyeExpression : undefined
|
||||||
|
}
|
||||||
|
playback={
|
||||||
|
activeSequenceLabel && playbackStatus !== 'stopped'
|
||||||
|
? { name: activeSequenceLabel, status: playbackStatus }
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onManipulationStart={freezeLivePreviewForManipulation}
|
||||||
|
/>
|
||||||
|
{photoFlash > 0 && (
|
||||||
|
<motion.div
|
||||||
|
className="photo-flash"
|
||||||
|
key={photoFlash}
|
||||||
|
aria-hidden="true"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: [0, 0.92, 0] }}
|
||||||
|
transition={{ duration: 0.38, times: [0, 0.16, 1], ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="photo-capture-bar">
|
||||||
|
<Button className="photo-capture-button" type="button" onClick={takePicture}>
|
||||||
|
<Camera />
|
||||||
|
{t('Prendre une photo')}
|
||||||
|
<span className="photo-format-badge">{snapshotFormat.toUpperCase()}</span>
|
||||||
|
</Button>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
className="photo-help-button"
|
||||||
|
variant="secondary"
|
||||||
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
|
aria-label={t('Informations sur le mode photo')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Info />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="photo-help-tooltip">
|
||||||
|
{t('Tu peux modifier le format, le fond et la définition du mode photo dans Export.')}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</motion.section>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
src/features/studio/components/StudioView.tsx
Normal file
14
src/features/studio/components/StudioView.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { StudioDialogs } from '@/features/studio/components/StudioDialogs'
|
||||||
|
import { StudioInspector } from '@/features/studio/components/StudioInspector'
|
||||||
|
import { StudioStage } from '@/features/studio/components/StudioStage'
|
||||||
|
import type { StudioController } from '@/features/studio/useStudioController'
|
||||||
|
|
||||||
|
export function StudioView(controller: StudioController) {
|
||||||
|
return (
|
||||||
|
<div className="studio" lang={controller.language}>
|
||||||
|
<StudioStage controller={controller} />
|
||||||
|
<StudioInspector controller={controller} />
|
||||||
|
<StudioDialogs controller={controller} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -3,9 +3,13 @@ import {
|
|||||||
parseExpressions,
|
parseExpressions,
|
||||||
type AvatarBehaviorLibrary,
|
type AvatarBehaviorLibrary,
|
||||||
type AvatarLibrary,
|
type AvatarLibrary,
|
||||||
} from './avatars'
|
} from '../avatar/avatars'
|
||||||
import type { Expression } from './geometry'
|
import type { Expression } from '../avatar/geometry'
|
||||||
import { normalizeSequencesForExpressions, parseSequences, type AvatarSequence } from './sequences'
|
import {
|
||||||
|
normalizeSequencesForExpressions,
|
||||||
|
parseSequences,
|
||||||
|
type AvatarSequence,
|
||||||
|
} from '../animation/sequences'
|
||||||
import defaultStudioDocument from './defaultStudioDocument.json'
|
import defaultStudioDocument from './defaultStudioDocument.json'
|
||||||
|
|
||||||
export type StatePlaybackSelection = { stateId: string | null; playing: boolean }
|
export type StatePlaybackSelection = { stateId: string | null; playing: boolean }
|
||||||
1766
src/features/studio/useStudioController.ts
Normal file
1766
src/features/studio/useStudioController.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
|||||||
import { translateStudioText } from '../i18n'
|
import { translateStudioText } from '@/i18n'
|
||||||
|
|
||||||
describe('avatar studio translations', () => {
|
describe('avatar studio translations', () => {
|
||||||
it('uses English for static interface copy', () => {
|
it('uses English for static interface copy', () => {
|
||||||
@ -6,7 +6,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { chinese, translateChineseDynamicText } from './i18n.zh'
|
import { chinese, translateChineseDynamicText } from './zh'
|
||||||
|
|
||||||
export type StudioLanguage = 'en' | 'fr' | 'zh-CN'
|
export type StudioLanguage = 'en' | 'fr' | 'zh-CN'
|
||||||
|
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|
||||||
import App from './App'
|
import App from './app/App'
|
||||||
import './styles.css'
|
import './app/styles.css'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const root = path.dirname(fileURLToPath(import.meta.url))
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
root,
|
root,
|
||||||
|
base: './',
|
||||||
plugins: [react(), babel({ presets: [reactCompilerPreset()] }), tailwindcss()],
|
plugins: [react(), babel({ presets: [reactCompilerPreset()] }), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
@ -1,9 +1,19 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
const root = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.join(root, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
globals: true,
|
globals: true,
|
||||||
include: ['src/__tests__/**/*-test.ts'],
|
include: ['src/**/__tests__/**/*-test.ts'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user