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:
smontlouis 2026-08-13 18:56:47 +02:00
parent fe00d640fb
commit 3b2e134169
74 changed files with 8627 additions and 8140 deletions

33
.github/workflows/ci.yml vendored Normal file
View 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
View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

13
public/favicon.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Allow: /

31
public/site.webmanifest Normal file
View 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"
}
]
}

View File

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

File diff suppressed because it is too large Load Diff

16
src/app/App.tsx Normal file
View 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>
)
}

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

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

View File

@ -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', () => {

View File

@ -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', () => {

View 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>
</>
)
}

View File

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

View File

@ -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', () => {

View File

@ -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', () => {

View File

@ -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', () => {

View File

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

View File

@ -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', () => {

View File

@ -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', () => {

View File

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

View 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>
</>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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', () => {

View File

@ -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', () => {

View File

@ -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', () => {

View File

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

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

View File

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

View File

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

View File

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

View 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>
</>
)
}

File diff suppressed because it is too large Load Diff

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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', () => {

View File

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

View File

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

View File

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

View File

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

1992
yarn.lock

File diff suppressed because it is too large Load Diff