From 3b2e134169f23fe5bfbee478f6b742d1c53702f9 Mon Sep 17 00:00:00 2001 From: smontlouis Date: Thu, 13 Aug 2026 18:56:47 +0200 Subject: [PATCH] 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. --- .github/workflows/ci.yml | 33 + .gitignore | 7 +- .prettierignore | 8 +- .yarnrc.yml | 1 - AGENTS.md | 16 +- CONTEXT.md | 8 +- README.md | 173 +- components.json | 2 +- index.html | 27 +- package.json | 9 +- pnpm-lock.yaml | 1695 +++++ public/apple-touch-icon.png | Bin 0 -> 12535 bytes public/favicon.svg | 13 + public/icon-192.png | Bin 0 -> 13246 bytes public/icon-512.png | Bin 0 -> 57440 bytes public/robots.txt | 2 + public/site.webmanifest | 31 + scripts/generate-standalone-engine.mjs | 6 +- src/App.tsx | 6015 ----------------- src/app/App.tsx | 16 + src/app/components/common.tsx | 195 + src/app/components/controls.tsx | 220 + src/app/studio-utils.ts | 140 + src/{ => app}/styles.css | 0 .../animation}/__tests__/playback-test.ts | 4 +- .../animation}/__tests__/sequences-test.ts | 4 +- .../components/SequenceWorkspace.tsx | 505 ++ src/{ => features/animation}/playback.ts | 0 src/{ => features/animation}/sequences.ts | 4 +- .../avatar}/__tests__/ambient-motion-test.ts | 8 +- .../avatar}/__tests__/avatars-test.ts | 8 +- .../__tests__/expression-editing-test.ts | 8 +- .../avatar}/__tests__/geometry-editor-test.ts | 8 +- .../__tests__/manipulation-session-test.ts | 2 +- .../avatar}/__tests__/presets-test.ts | 2 +- src/{ => features/avatar}/ambientMotion.ts | 0 src/{ => features/avatar}/avatars.ts | 6 +- src/{ => features/avatar}/body.ts | 0 .../avatar/components/ExpressionWorkspace.tsx | 496 ++ .../avatar}/expressionEditing.ts | 0 src/{ => features/avatar}/geometry.ts | 0 .../avatar}/manipulationSession.ts | 0 src/{ => features/avatar}/presets.ts | 0 src/{ => features/avatar}/surfaces.ts | 0 .../export}/__tests__/exporter-test.ts | 8 +- .../__tests__/snapshot-exporter-test.ts | 10 +- src/{ => features/export}/exporter.ts | 8 +- .../export}/proceduralBrowserRuntime.ts | 0 src/{ => features/export}/snapshotExporter.ts | 4 +- .../export}/standaloneEngine.entry.ts | 4 +- .../export}/standaloneEngine.generated.ts | 0 src/{ => features/export}/storedZip.ts | 0 .../__tests__/canvas-preview-test.ts | 6 +- .../__tests__/rendered-rotation-gizmo-test.ts | 7 +- .../__tests__/rendered-scene-test.ts | 18 +- src/{ => features/rendering}/canvasPreview.ts | 4 +- .../rendering/components/AvatarCanvas.tsx | 933 +++ .../rendering}/renderedRotationGizmo.ts | 2 +- src/{ => features/rendering}/renderedScene.ts | 6 +- .../studio}/__tests__/studio-document-test.ts | 8 +- .../studio/components/StudioDialogs.tsx | 129 + .../studio/components/StudioInspector.tsx | 1977 ++++++ .../studio/components/StudioStage.tsx | 168 + src/features/studio/components/StudioView.tsx | 14 + .../studio}/defaultStudioDocument.json | 0 src/{ => features/studio}/studioDocument.ts | 10 +- src/features/studio/useStudioController.ts | 1766 +++++ src/{ => i18n}/__tests__/i18n-test.ts | 2 +- src/{i18n.ts => i18n/index.ts} | 2 +- src/{i18n.zh.ts => i18n/zh.ts} | 0 src/main.tsx | 4 +- vite.config.ts | 1 + vitest.config.ts | 12 +- yarn.lock | 1992 ------ 74 files changed, 8627 insertions(+), 8140 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .yarnrc.yml create mode 100644 pnpm-lock.yaml create mode 100644 public/apple-touch-icon.png create mode 100644 public/favicon.svg create mode 100644 public/icon-192.png create mode 100644 public/icon-512.png create mode 100644 public/robots.txt create mode 100644 public/site.webmanifest delete mode 100644 src/App.tsx create mode 100644 src/app/App.tsx create mode 100644 src/app/components/common.tsx create mode 100644 src/app/components/controls.tsx create mode 100644 src/app/studio-utils.ts rename src/{ => app}/styles.css (100%) rename src/{ => features/animation}/__tests__/playback-test.ts (92%) rename src/{ => features/animation}/__tests__/sequences-test.ts (97%) create mode 100644 src/features/animation/components/SequenceWorkspace.tsx rename src/{ => features/animation}/playback.ts (100%) rename src/{ => features/animation}/sequences.ts (99%) rename src/{ => features/avatar}/__tests__/ambient-motion-test.ts (91%) rename src/{ => features/avatar}/__tests__/avatars-test.ts (89%) rename src/{ => features/avatar}/__tests__/expression-editing-test.ts (82%) rename src/{ => features/avatar}/__tests__/geometry-editor-test.ts (93%) rename src/{ => features/avatar}/__tests__/manipulation-session-test.ts (96%) rename src/{ => features/avatar}/__tests__/presets-test.ts (93%) rename src/{ => features/avatar}/ambientMotion.ts (100%) rename src/{ => features/avatar}/avatars.ts (98%) rename src/{ => features/avatar}/body.ts (100%) create mode 100644 src/features/avatar/components/ExpressionWorkspace.tsx rename src/{ => features/avatar}/expressionEditing.ts (100%) rename src/{ => features/avatar}/geometry.ts (100%) rename src/{ => features/avatar}/manipulationSession.ts (100%) rename src/{ => features/avatar}/presets.ts (100%) rename src/{ => features/avatar}/surfaces.ts (100%) rename src/{ => features/export}/__tests__/exporter-test.ts (96%) rename src/{ => features/export}/__tests__/snapshot-exporter-test.ts (77%) rename src/{ => features/export}/exporter.ts (98%) rename src/{ => features/export}/proceduralBrowserRuntime.ts (100%) rename src/{ => features/export}/snapshotExporter.ts (96%) rename src/{ => features/export}/standaloneEngine.entry.ts (68%) rename src/{ => features/export}/standaloneEngine.generated.ts (100%) rename src/{ => features/export}/storedZip.ts (100%) rename src/{ => features/rendering}/__tests__/canvas-preview-test.ts (82%) rename src/{ => features/rendering}/__tests__/rendered-rotation-gizmo-test.ts (73%) rename src/{ => features/rendering}/__tests__/rendered-scene-test.ts (79%) rename src/{ => features/rendering}/canvasPreview.ts (84%) create mode 100644 src/features/rendering/components/AvatarCanvas.tsx rename src/{ => features/rendering}/renderedRotationGizmo.ts (97%) rename src/{ => features/rendering}/renderedScene.ts (95%) rename src/{ => features/studio}/__tests__/studio-document-test.ts (96%) create mode 100644 src/features/studio/components/StudioDialogs.tsx create mode 100644 src/features/studio/components/StudioInspector.tsx create mode 100644 src/features/studio/components/StudioStage.tsx create mode 100644 src/features/studio/components/StudioView.tsx rename src/{ => features/studio}/defaultStudioDocument.json (100%) rename src/{ => features/studio}/studioDocument.ts (95%) create mode 100644 src/features/studio/useStudioController.ts rename src/{ => i18n}/__tests__/i18n-test.ts (97%) rename src/{i18n.ts => i18n/index.ts} (99%) rename src/{i18n.zh.ts => i18n/zh.ts} (100%) delete mode 100644 yarn.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5b357b9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 54a42d6..f0e0c43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,6 @@ dist/ node_modules/ -.yarn/ -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions +pnpm-debug.log* *.local .DS_Store coverage/ diff --git a/.prettierignore b/.prettierignore index 4754fa6..7a4ddb9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,8 @@ dist/ node_modules/ legacy/ -src/defaultStudioDocument.json -src/standaloneEngine.generated.ts -yarn.lock +public/favicon.svg +public/robots.txt +src/features/studio/defaultStudioDocument.json +src/features/export/standaloneEngine.generated.ts +pnpm-lock.yaml diff --git a/.yarnrc.yml b/.yarnrc.yml deleted file mode 100644 index 3186f3f..0000000 --- a/.yarnrc.yml +++ /dev/null @@ -1 +0,0 @@ -nodeLinker: node-modules diff --git a/AGENTS.md b/AGENTS.md index 55dcece..fd041c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,11 +8,11 @@ Read `CONTEXT.md` before changing behavior or persistence semantics. ## Commands ```bash -yarn dev -yarn typecheck -yarn test -yarn build -yarn check +pnpm dev +pnpm typecheck +pnpm test +pnpm build +pnpm check ``` ## Engineering rules @@ -22,9 +22,9 @@ yarn check - Preserve the split between durable React state and high-frequency Motion values. - Do not use `useMemo`, `useCallback` or `memo`; React Compiler handles memoization. - Add or update focused tests for domain behavior. -- Run `yarn typecheck` and the relevant test file while working, then `yarn check` before committing. -- `src/standaloneEngine.generated.ts` is generated. Update it with `yarn engine`, never by hand. -- Keep English, French and Simplified Chinese copy synchronized across `src/i18n.ts` and `src/i18n.zh.ts`. +- Run `pnpm typecheck` and the relevant test file while working, then `pnpm check` before committing. +- `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/index.ts` and `src/i18n/zh.ts`. - Preserve project JSON compatibility only with the current pre-release schema unless a migration is explicitly requested. ## UI conventions diff --git a/CONTEXT.md b/CONTEXT.md index 19e9b57..6581ef5 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -56,14 +56,16 @@ manipulation pauses it. - The primary body shape carries the facial coordinate system and eyes. - Secondary primitives have independent local dimensions, position and rotation. - Expressions remain compatible across body surfaces because they operate in the common facial frame. -- `geometry.ts` and the exported procedural engine stay independent from React. -- `defaultStudioDocument.json` is the current schema baseline; pre-release legacy migrations are not required. +- `features/avatar/geometry.ts` and the exported procedural engine stay independent from React. +- `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. ## Architecture boundaries - Pure geometry and document logic live in framework-independent TypeScript modules. -- `App.tsx` coordinates UI and application state; avoid moving domain calculations into components. +- `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. - 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. diff --git a/README.md b/README.md index b61f2e2..548f5c3 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,160 @@ # Bible Strong Avatar Lab -Standalone React/Vite studio for creating procedural 2D avatars, expressions and animations. -The project was extracted from Bible Strong so it can evolve independently. +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. -## 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 -corepack enable -yarn install -yarn dev +pnpm install +pnpm 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 +Before opening a pull request, run the complete local check: + ```bash -yarn typecheck -yarn test -yarn build +pnpm check ``` -`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. -- `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. +Create the static production bundle: -## Product model +```bash +pnpm build +``` -- An avatar owns its body and neutral eye appearance. -- The bundled behavior library supplies the initial expressions and animations. -- On the first behavior edit, an avatar receives its own independent copy of both. -- An animation references expressions from the same behavior library. -- The Studio document can be exported/imported as JSON for portability. +The deployable site is written to `dist/`. Serve it locally before publishing: -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). diff --git a/components.json b/components.json index b86ac10..827348a 100644 --- a/components.json +++ b/components.json @@ -5,7 +5,7 @@ "tsx": true, "tailwind": { "config": "", - "css": "src/styles.css", + "css": "src/app/styles.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" diff --git a/index.html b/index.html index 8c86814..9d28d74 100644 --- a/index.html +++ b/index.html @@ -1,10 +1,33 @@ - + - Bible Strong Avatar Lab + + + + + + + + + + + + + + + Bible Strong Avatar Lab — Procedural 2D Avatar Studio
diff --git a/package.json b/package.json index e239415..804c16c 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,16 @@ { "name": "bible-strong-avatar-lab", "version": "0.1.0", + "description": "A browser-based studio for designing procedural 2D avatars, expressions and reusable animations.", "private": true, "type": "module", - "packageManager": "yarn@4.12.0", + "packageManager": "pnpm@11.21.0", "engines": { "node": ">=22.12.0" }, "scripts": { - "dev": "yarn engine && vite", - "build": "yarn engine && vite build", + "dev": "pnpm engine && vite", + "build": "pnpm engine && vite build", "preview": "vite preview", "engine": "node scripts/generate-standalone-engine.mjs", "engine:check": "node scripts/generate-standalone-engine.mjs --check", @@ -18,7 +19,7 @@ "test:watch": "vitest", "format": "prettier --write .", "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": { "@base-ui/react": "^1.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..0b6ddbb --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1695 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@base-ui/react': + specifier: ^1.7.0 + version: 1.7.0(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + lucide-react: + specifier: ^1.31.0 + version: 1.31.0(react@19.2.3) + motion: + specifier: ^13.0.0 + version: 13.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: + specifier: 19.2.3 + version: 19.2.3 + react-dom: + specifier: 19.2.3 + version: 19.2.3(react@19.2.3) + tailwind-merge: + specifier: ^3.6.0 + version: 3.6.0 + devDependencies: + '@babel/core': + specifier: ^7.29.0 + version: 7.29.7 + '@babel/parser': + specifier: ^7.28.6 + version: 7.29.8 + '@rolldown/plugin-babel': + specifier: ^0.2.3 + version: 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.4)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)) + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)) + '@types/node': + specifier: ^24.10.1 + version: 24.13.3 + '@types/react': + specifier: ^19.2.14 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.4(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^6.0.2 + version: 6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.4)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)) + babel-plugin-react-compiler: + specifier: ^1.0.0 + version: 1.0.0 + prettier: + specifier: ^3.8.1 + version: 3.9.6 + rolldown: + specifier: ^1.0.0-rc.5 + version: 1.2.4 + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 + typescript: + specifier: ~6.0.3 + version: 6.0.3 + vite: + specifier: ^8.0.13 + version: 8.2.1(@types/node@24.13.3)(jiti@2.7.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@24.13.3)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@base-ui/react@1.7.0': + resolution: {integrity: sha512-j+8QjX44C32jrXD/qyEAGpFr70FRpGL2CY61mQd9nBPWN737CK0xxD1ceJ055rW4RtdvFDT1e7otzdlfxvsYug==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@date-fns/tz': ^1.2.0 + '@types/react': ^17 || ^18 || ^19 + date-fns: ^4.0.0 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@date-fns/tz': + optional: true + '@types/react': + optional: true + date-fns: + optional: true + + '@base-ui/utils@0.3.2': + resolution: {integrity: sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.144.0': + resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} + + '@rolldown/binding-android-arm64@1.2.4': + resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.4': + resolution: {integrity: sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.4': + resolution: {integrity: sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.4': + resolution: {integrity: sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.4': + resolution: {integrity: sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.4': + resolution: {integrity: sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.4': + resolution: {integrity: sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.4': + resolution: {integrity: sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.4': + resolution: {integrity: sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.4': + resolution: {integrity: sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.4': + resolution: {integrity: sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.4': + resolution: {integrity: sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.4': + resolution: {integrity: sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.4': + resolution: {integrity: sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/plugin-babel@0.2.3': + resolution: {integrity: sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw==} + engines: {node: '>=22.12.0 || ^24.0.0'} + peerDependencies: + '@babel/core': ^7.29.0 || ^8.0.0-rc.1 + '@babel/plugin-transform-runtime': ^7.29.0 || ^8.0.0-rc.1 + '@babel/runtime': ^7.27.0 || ^8.0.0-rc.1 + rolldown: ^1.0.0-rc.5 + vite: ^8.0.0 + peerDependenciesMeta: + '@babel/plugin-transform-runtime': + optional: true + '@babel/runtime': + optional: true + vite: + optional: true + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@vitejs/plugin-react@6.0.5': + resolution: {integrity: sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + babel-plugin-react-compiler@1.0.0: + resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + + baseline-browser-mapping@2.11.13: + resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001809: + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + electron-to-chromium@1.5.405: + resolution: {integrity: sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==} + + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + engines: {node: '>=10.13.0'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + framer-motion@13.1.0: + resolution: {integrity: sha512-QSZrF0Id3QGuHJ+OL+9PSY9pk86C8ERFalwAGSchzTm65+ZoGH/RM26lmEARLljcHj2lqhv0jZOOks+EI3COOw==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.31.0: + resolution: {integrity: sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + motion-dom@13.0.0: + resolution: {integrity: sha512-Xk+SJas70uMAUIApg+m3lZDShxI3LBFHq7mFGbBKoRXc2PVPDyAKmzN64Bbzt4CZdP/CItTiJxWtn4TA0v53Ng==} + + motion-utils@13.0.0: + resolution: {integrity: sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==} + + motion@13.1.0: + resolution: {integrity: sha512-qtvscq59uCPdWnNW4SdSkrxR+BS/QYsa923bx7ocA+4p+ZGNbbVQwkSnG4aukB81QWjtl3AxX36plxNyZLmHCA==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + engines: {node: '>=18'} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + react-dom@19.2.3: + resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} + peerDependencies: + react: ^19.2.3 + + react@19.2.3: + resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} + engines: {node: '>=0.10.0'} + + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + + rolldown@1.2.4: + resolution: {integrity: sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + update-browserslist-db@1.3.1: + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vite@8.2.1: + resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.8 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@base-ui/react@1.7.0(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@babel/runtime': 7.29.7 + '@base-ui/utils': 0.3.2(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@floating-ui/utils': 0.2.12 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + + '@base-ui/utils@0.3.2(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@babel/runtime': 7.29.7 + '@floating-ui/utils': 0.2.12 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + reselect: 5.2.0 + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + '@floating-ui/utils@0.2.12': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@oxc-project/types@0.144.0': {} + + '@rolldown/binding-android-arm64@1.2.4': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.4': + optional: true + + '@rolldown/binding-darwin-x64@1.2.4': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.4': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.4': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.4': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.4': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.4': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.4': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.4': + optional: true + + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.4)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))': + dependencies: + '@babel/core': 7.29.7 + picomatch: 4.0.5 + rolldown: 1.2.4 + optionalDependencies: + '@babel/runtime': 7.29.7 + vite: 8.2.1(@types/node@24.13.3)(jiti@2.7.0) + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.5 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.2.1(@types/node@24.13.3)(jiti@2.7.0) + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/react-dom@19.2.4(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.4)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.2.1(@types/node@24.13.3)(jiti@2.7.0) + optionalDependencies: + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.4)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)) + babel-plugin-react-compiler: 1.0.0 + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.1(@types/node@24.13.3)(jiti@2.7.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + assertion-error@2.0.1: {} + + babel-plugin-react-compiler@1.0.0: + dependencies: + '@babel/types': 7.29.8 + + baseline-browser-mapping@2.11.13: {} + + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.13 + caniuse-lite: 1.0.30001809 + electron-to-chromium: 1.5.405 + node-releases: 2.0.53 + update-browserslist-db: 1.3.1(browserslist@4.28.8) + + caniuse-lite@1.0.30001809: {} + + chai@6.2.2: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + convert-source-map@2.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: {} + + electron-to-chromium@1.5.405: {} + + enhanced-resolve@5.24.5: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + es-module-lexer@2.3.1: {} + + escalade@3.2.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + framer-motion@13.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + motion-dom: 13.0.0 + motion-utils: 13.0.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + graceful-fs@4.2.11: {} + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@1.31.0(react@19.2.3): + dependencies: + react: 19.2.3 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + motion-dom@13.0.0: + dependencies: + motion-utils: 13.0.0 + + motion-utils@13.0.0: {} + + motion@13.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + framer-motion: 13.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + node-releases@2.0.53: {} + + obug@2.1.4: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.9.6: {} + + react-dom@19.2.3(react@19.2.3): + dependencies: + react: 19.2.3 + scheduler: 0.27.0 + + react@19.2.3: {} + + reselect@5.2.0: {} + + rolldown@1.2.4: + dependencies: + '@oxc-project/types': 0.144.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.4 + '@rolldown/binding-darwin-arm64': 1.2.4 + '@rolldown/binding-darwin-x64': 1.2.4 + '@rolldown/binding-freebsd-x64': 1.2.4 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.4 + '@rolldown/binding-linux-arm64-gnu': 1.2.4 + '@rolldown/binding-linux-arm64-musl': 1.2.4 + '@rolldown/binding-linux-ppc64-gnu': 1.2.4 + '@rolldown/binding-linux-s390x-gnu': 1.2.4 + '@rolldown/binding-linux-x64-gnu': 1.2.4 + '@rolldown/binding-linux-x64-musl': 1.2.4 + '@rolldown/binding-openharmony-arm64': 1.2.4 + '@rolldown/binding-win32-arm64-msvc': 1.2.4 + '@rolldown/binding-win32-x64-msvc': 1.2.4 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + tailwind-merge@3.6.0: {} + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + tslib@2.8.1: {} + + typescript@6.0.3: {} + + undici-types@7.18.2: {} + + update-browserslist-db@1.3.1(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-sync-external-store@1.6.0(react@19.2.3): + dependencies: + react: 19.2.3 + + vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + jiti: 2.7.0 + + vitest@4.1.10(@types/node@24.13.3)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.1(@types/node@24.13.3)(jiti@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yallist@3.1.1: {} diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6034b39b0f2cdd0bbb3ed03171fe8b5c67129dee GIT binary patch literal 12535 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 + Strobi + + + + + + + + + + + diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..07e0dace58979bba33b741935542b71632b8cbd9 GIT binary patch literal 13246 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 Q5eER|-zdqHOBsLqADz|{w*UYD literal 0 HcmV?d00001 diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..10fa206902ff65d001d27a3b5d103b603018a2c6 GIT binary patch literal 57440 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⋘RFBdwRqk#GRrnroFKir5MvQxC$b<%tJOhX zlBx#|`4y`ueHGeJqv1khtB78HbT~0r?v*b+V(QW{%tZV0{Y@8!{$V|T9bLpdaxHxF zBEGL{@CG7e-P0w;CdNaxQ56uC!jTzcSYR2otbDWE{;7|5Eb)w@U$kUU!H8U|i0`cq8cBh8<%t6yx{tsUMHW}qKfUp?vu2l6+?UN z1(G_&NAAc)cB5j>6Ypl(>y5X!#@FhUZ9JKBsefrQG5y;J0Y(*Mycv@(fj*0ZOzh-Kv*9P~J% zl$Wl0x44Q@7H*(8;r5YOvU|jSZ}7$@5`Xeg-lc(O`}Z}4r|4|pUe#a%T= zAa>*CY7mvGKajYL{~V7Q-={;(~g6#FlpEn?qd!G{g5E^ET2{OrVgNoLH2MRB}Y9_zC- zot;LzL}DtJ>m)O_tD=F$z46iF0w78S-Ll8OsFY(&3L;7v9?5Qa_y{3MV zofAC4Ag7LDx*C(#FOXk7kelgiQ$c6p`*5r80(PM9shOKA!HIUVhSCe|jvUyn-qz>C zc>5@r$LMuNAVq)xy!PyJ*RgY8ABpW>OFY+LNl>|#tZ+)+&CmzsnSo@m@&W|EZkg+`;*Iy+ z$L3Hhhs7liO?n>ya7e(p%cvU}uNhDmb9GnkY6TCxgNe142w$H7Vr%5p!l|$7N0+aK z$-c>bkLY|lz86w3F#0S4jvvD=20#;mJ36|(D^86IiR9HCa)7TDux12@6R55BmvsL# z;5^{K2&(L-ie9mVW}AlP6d2A~svC#znkNG}kruGv+Q%}-4n~>{c9L0*OJXcs)BP5% zV&{(xw=|beEW0JcOQ}3{tFl6e3}hu>>4ZyTF}k}2epR>-;+?}Aa&tV`_0is*5q7)6 z$|1GJ4fD#Pu)QL-1J+}bs!X6!(dAPc?#lCp>Xk}Zv242}y)=)Nb%@u2dTztGK<>uDlM(qwe z&t&fYR~PL1NPn@GA^)?vLu{m}m04_(C4jhl#kteXkt~qdvAf;f1)ru4E$#2&Qqu}5 zt`?4o0l{a5xgVtBMV$ELB?EG3Y3$1KE+}b!FlO;D*+TL0*oli{qs8a_%G(6TwkYT5 zCSCtsp)s0b+kIs>59Cn@)PB&;(OnNc`UaakdiSNB;fm939-58Uqq2rh5ZJELgMviL zrkmpnF8$ckhfY-bkz zPtD|R`!fKi_vOe2;MK8U3W`t!Ick+-En5E?GMA06TRKY0qpS%%B z>fNnq4s2fmt`AphWmY*{dT~MSMEONHa7xhfk%Q1)I$l~z>8n??KMx=K;=O(oZ#CJ! z;v~a-O={bGC{L9z&^u&ByOtp6B;p%w(+JGe8mLfMY_S}A%MNxW0~b*b{07usK1z; zO#$SuzNc`aZsuwc;^*iIDn<6g3d7-T=LQtd^%e_W+)~-5@o(NJ)Fn{FQIdv7TMl~9 z3gDRyk#;Y2eo6mQvNYcD^9bPc7LtttffVQQC)_hB(=$WRdDDm&KcC?j$k;akf#VpA zPF!m&Brz@jOqmh_7A($(^teNA{vg}@B6#4@IltFLhIqPS_Z)b;^6K%)`5ZY3s%?v4 zlN+Erh-zr&F(rZgA`j`qW|v-muL`a>-p!l%{wah)<$=@>lg)Ktk3~+9r0_sNe4eVY zWtoihb ztFd#@Pld@7=Lt9JV~I9Jc5KzRWyS`}-;`Q;%lm1lTwfpo)cwb-kHs%Ir1g=#nXT|% zrtIw(FLeg|dIk^t5HcbxNdx&llWufQvHtdazubH*x7R<97~lkR)YN! zRhq!vE=Xf?X50gT*#isqnxSccngX#@ZgT!ZqBH3IBaS%|5;EnhxfQPgWnsg0`bf79 zJpr9Y&{62)Cm?zpqW;z8~x8(IUMi3cmQy91o))C%)DLFwL9pPRb>2fqkm%eU0}AS&aR_1?=|c4jIC*I&D;AP zq!Qa?L8WQwaJA6UJBq`1>9P*!Yw2Y`{x_T84DK>O(!6=ruS(CM7TcWEf1?XmDSu>w zPnARNO7+~ORIVSJGFfwKZ$tu!R_l>$}#>p0NT<% zS$BNRHL-EO(SKrt9-$^d2YNL>u}3O7LX(@-l>5zIqzN>dm-UAyTYHr6w9F^hMHf|$ zlp+aE0P|qzf}gp=KJ`oLoMR0!<||fT%>Le`^0vl4GkCIo$6EPN<8o~hN#0h6;B;2g z`a1|TgK>hZoNRZUO>#0o>sDNLOTS0p-!6f7^XZ0HNU&8qv{=oS@4_icowth(+n0Y8=_}+5BylaxG^=^zj89Tq> zzmILh_utqx#0TWNs<$u!AfpIbzEkhETXpkrbpAWC0ncLarxG@V z@$Fx+EH!rO^DR~HNb+ARIQ$&PHjA87lbB39XV9~fqmn1H&# zk1hpJ-30vtu$d{Vc2Aj_?(pUK?Wo{$WOw`)S+Mb__9xEkDO0EXW2_iKgtvQuuLv9) ziOFPbyt2-QPi@BvuMgZ<;>0J#+7V4zUxvOvYdI-C+NS})Zk@5sNYQk5XZ~$JWD@c1 z7DNoFYR0ghm2d|Vkin+g4Vjhi{h<^UzAv;H{-UZ%IUQ~Ha$(VHL*sil@zQO`BpZPf zfx7)G$l4Pb`yu<={~UzTgZw`}btV{%nvmxr5cd>sZOzg{=0~SU$M_bdsZAE{>Y+ua zt(xdmOaU61B2dD6Y?sOGpL5E!wJxCZPt^R`4kL+_!9eAont0#&)t{Z70-P2cDR+Um zSUcvbT~sxBPpPfQ{n3_b{4^;HzY)KaK-|uQvXI8whgK27N2UQ3Mi4?C4xC}=J_(%_ zkaQvpKNVz9lIl&cJ=|d{Los%j$ALz6=`K(&;}@>u-&J<6TWiyp?=ZU5I|2Y+zom6R@Cm#`^E;q`uPIKf^ zBU@uh5GSMXGtg7~sp1^!)3M_o7007&5x_$z41rUhJbpd%@{Wd&nqwC$$7X}D_izv? zdtM-Lk2YMSnEzjO&)L;f*7XHcd@M8zN<0lKoY?K2BicDAqhwc>Hv;#r6+^{83Y1KLX?sqA@E$^5AS-vJU`&Q>#lV_+`HGl z`<#7tIlG+L{b6pqUU|l6QZJ6Zz@uhO&}PlKZY`;EF`!q}4O?DM8CRN|?69ozO$FAh z5g-ggtNvqhzypg~f@yG{$2=4TxxxN%aeFy6oB_}AIvq4G+Q%v?yfdi+bZw7}8Vt{3 z!*vI@uA(AOuBMPbu1*<|{P0~vSMx-fuv8a%NMFM9P9*?Mum5D^tu*)K%li$2uM8Df z1g443vtIhwUeukj{nK1-2Hr=*^E9vO&RQCl9^{8WDy`GoueEkGbKudN81k%N%uUh9 zM#QUzVhcWZwsYx^*mg{~cQNr>j=GWvXmMVv0F;slGQsQGMa}#^zt}Gmvc()yn?5aZ z){&&F&5ZqtI_tD#J9YN?Yr!>PP*8zR)n6 zHbTNMU!D+^7b0wR5n@t1v-v%B6WrsbLQHEi{lXkMH5||3+>w04>^SKu(4>_792NId zt~-NW4tlHMe#qro)C$b>W_-cm)7{+d9n5!Izj2>tkpSyChpuGZFz`~}j*S|da+t@b?UDd{}MVx#jYlF7<%ZBO`{g1zd;70ITT(oYa*%a zR#1Kw5N2oV^oD7KW~lYS?qI~4bt$j;Z4PehY$<$cf1WolV$&IF$Rvg10;JguT)29c z-4U@oKfzULY|f_8tkJPwPLmj7X{XNg?U5s_JCY9!?Uh0b=NDiQDp>{AwDOKu6+O$E z@X`BphjbRA zx7KjxFAvMAEt$BD&q9n)>E-KtN8ck{@?X_)K=>KO;UHt{K9t>$V-D;!I}b6o~6eX#gWRe&$^|odDf-EbV#(fDjH|vYm1SRkeRh4G^hVy z%>l*5jPMF5d20wH`85^Q{Z%}bgU4a4?2+$;9UOZ=YZt`gVP%9%!PE{Lt;5eKbpctT zUz3`6>LfKR+{N0N>jIn~s6KebMwnblF&^zOQ(;lA*n-S~lXhOu@8p z#Hwr*BY8Pry-NhyU3T?B=4%=>KD`k&zDe)b*%AFpq>b{*{s&)ctfFN5&d-7ZcC`vVl&CY&DAba z_qx78^r|>%6L9CF!a9tDnm{^MZW13Q>Ik^;H@C&+g*&pFE_=o1I*kegFD^MQg}eEd zcv{Ek3fhRaX@m?Y9YE(6xanJp^atA@vT!TfXQHG-yWLgVybk(aReDj@?azICOBH>`uo6zwZAur^&TNI4>|vO`QxfN0v&x>*wN-%x`Xa9ls zvKV9J)1_?8$Z3+c;HhC`oBb-i#%ry!TbIxj2;1YQPQ`LUiwHpm)-L9O%!}_*yAQtk zBevBLG$|kW%9AN#nM#}Clv)h+(A!c0o(Bu`>k{NgMQjnY>!kqy`bo1tq~l(&P5cwL zm;_&hU5LFxUH3Ke(5ge(pFWo! zV+x2DKsv7U&A_8;6|wMozWPk#>Pw40g7H*{zS>TG5xI_dOOaJ3FIZY1R8P9-%A$d9)4 z5CQ@Y*aC1U$r|uS-7~YNltg~TKB9$#Og$iZclWVoo3sa2f$3_i#rzhBks~-Ft~#0W z>DU?bMc@uo-B5ey%$%JI`GT5Sz5#S=`^j7L%(vB5pF{~+h@%uZhUncjmnm5h$N zWM>A|CAK_bwbTlJFLFcb@DEvhD?+~6?42f1uM43!Y#KP@o;QJnjh3=mpWWsd4eH72abn;k?sY14O*@(GW)BO=6tu)2aV8T;g5I!BE?GN zEx#+QtN*q^ZTBnijDyC~Y1CvX9aFc>p7?y%JZcSa&fq6H!=_x-fOemu1k3)?hy?mq zo{}5B`>4x(!% zp)JFty)tR40zsN^33n6pE$=Dwsb1L&u6>N){P2iv(E&j&{ak$CVPsgF)WSigVz>iZ zb!m%QIDR{}scR?_wM>15Js%Ls$*1OGC*$de24!W>P^S$=vz@nV`4(pJx=*A> z*n`x`G})`0`XSxv2zP^$A2g!aq^e}aSS)WTHiriFWz*1I*;D?em(|m7V;ttZ6Mm!6 zePe2;3gC4&YX=Z!6xnLGF?7S_N3c*-cBvR*?T3UH^O$Xw3XXed{qH)_)p4NA*kf$3c=~v=`-M? zq_tvQD7%mcCq=fA9yVAdayF)zoa`Z~ex0LV)II!VZkY{90;vcc~+q%&K ztB1FHE-WV!#+rsAlFHd_g7Vvbxx{66>oQMeJ_gEmsbr>BX4ww%B%yT?x2*~oOQ({R#_LA2vF>w@tLMyDUy*!pib~&vaM_9*<5fHmEMVRI4OwLPo`Cwmg5`RX z&vt!D1o7F4BYI;f!W@JO*-;;GRsix&3qDKmqhLATUd?Vrm zNDC=Ma0k!f`-ZwM@FdhaOqD)6HB~Uft~4j05SQDeo4H@r>=ckA41@{2u8c)tQ(GKX z$|6P0`(PGBF(lFTO2m8QMT@^>YA*FB8Ri4+$_FF%{jegcowEcVYPUy-Xak zNIn>3{Ad#6HF$rgTtVuW&D9U4?B+0c9&*g&dWYL`rfKiXn)6MVu%(ZW;nU^mdjeNA zjC^SN$aefTc`7(D$7#*y_wO8XJ;wy+ZMCjAU_oI7<5}8yGzJ6G6K-iZz z*HpRn?1hoRAChdo3UkbnxJQxrTTN!!Ay+tCKcG{9yiAqFdSPtYPnB?Nd8-34J`zVR zs~g=0stRt!3LHQk*bfu79*j964`K(MGyrs9`VhDJ=oF1LE9yTJtI$rscbOU+_hDBQ z$QjnU6Tj)GTaw{8`0v^a<`N4~16fUyTY=6A5d;S8wJ-8LC5lSL#d3Lf^W$rnyidO? z-eARa;y242K97yy7g%Hyu6!u=&G=MPBj3V;u^+}?xFKmZU4GpdE!w@(**!9FpZIzk z_5Q%4$FFgIJL#)4-KWKL<*ri_2kSTeJ^1+HEZJf}Qlzb4puqc$Ovri35UrbD?}0P= zRwkYd&Ql)4dA#=grUv5NPBQu|^Gh?k%bB^R=M`5f@=P)WK@jO9!EKE%a^!~^14**E zWuJ^)s9A_$DAxgKo*5^4b3G1!n+RHP5twyZ6t09c>|J6KjK*>InufU!x}LaeL~d5q zvNoteNYBRk?H5>Xs4kb;-5P#)Q{rIrw?l-hC4`WU}X%(Ev%`t(4mv?nmB;bTRp?Zc<`l6Zo#FL@%#!2aq3lLc#*#y;pQ zQlV~TI_?1o6-m`ZL9z!}^AYu!@)RF^qB6XA;KE!S?C;K6FGhO*kq@krK?_!~r4-+; zfLpB$^c1g;tC46~2^K7y{CUD)KY3shdBlo^n3zql!b@Uo=+DDC1s_;r;FBuJPJMFW zVWjHJ!0oz$Kh-Od*Dv=^;=sCjS(A1)`A}olC1cr?7u}Msq07T~+-{WWV@DbhlWqJU z@&W1K=Gvs|)=}nWL&g%iMD~x@q4lrMFqRBKXvGy)y`V}QIsKUENQQKdl}S3~hAtf7 z&gLwiVszlU+J8qFOO#v>=CWnkHhSA~XD7mF5lAzHY-8YIS|N*AfMQTu@r&)weM1<* z_I-lQm#S%_&h)IYtz{qc1C8274y%`p+*dW;Fd1+9EB`}Hkn2Z#*2}l_x%UKneHLt~ zYWVf7*n_kp!y)iU-OAWQ;^mCS%yoi%vtU=gzEx);f24}|`W~~_cy{WFYcFoxI>L3l zh=(3T#&lCj+GQ_G_~Er@L2 z3_Bw6wL3cNgPx$Ny0r<4q(*QDS&2+;_c<)~yHT5$*XeZe7g(yMksL^?{pp$8)$0@? zk2f$5NDDml0kwlleSl!Jo25PfkoHv5)9J=T)MNjbRN`~1CJlfG)zp+bu{9pN?*HTV zAdl$Wd*1_8leSv-{M~~upKj0q$j7yRW7KE>U-aft4JA8D)Pb7)Ke@&5vfI32gsZp+ zvh+~rd+DX~BRatMY+zu0Ly(d3GYDVIwPx)FoK2E;VgHrJMh@fqe2ioELmy#tuuLuQ zD8;e*EO|Ci+lKbaXw9Umj_9O4R$Z}C10F)XE1*?|?{f$>tl4@0gBnl;vGf0wDM5Ju zKU{X+i>btaoWhT)Gk;c_?2S9+5f>Z~7h)L{69PSeBc?}?hfI$iGBdwmhO~s6LASA~ zsimoDEaWPSW;gxk_C+IbB_LTF item.output) : re const chunk = outputs.find(item => item.type === 'chunk') if (!chunk) throw new Error('Standalone avatar engine bundle was not generated.') -const outputPath = path.join(root, 'src/standaloneEngine.generated.ts') +const 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` if (process.argv.includes('--check')) { const current = await readFile(outputPath, 'utf8').catch(() => '') - if (current !== source) throw new Error('Standalone avatar engine is stale. Run yarn engine.') + if (current !== source) throw new Error('Standalone avatar engine is stale. Run pnpm engine.') } else { await writeFile(outputPath, source) } diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 75e9096..0000000 --- a/src/App.tsx +++ /dev/null @@ -1,6015 +0,0 @@ -import { - animate, - motion, - useMotionValue, - useMotionValueEvent, - useReducedMotion, -} from 'motion/react' -import { - useEffect, - useEffectEvent, - useId, - useRef, - useState, - type CSSProperties, - type RefObject, -} from 'react' -import { - ArrowLeft, - Camera, - ChevronDown, - ChevronUp, - Copy, - Download, - FileCode2, - GripVertical, - Info, - Pause, - Pencil, - Play, - Plus, - RotateCcw, - Square, - Trash2, - Upload, -} from 'lucide-react' - -import { Button } from './components/ui/button' -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from './components/ui/accordion' -import { Badge } from './components/ui/badge' -import { Card, CardDescription, CardHeader, CardTitle } from './components/ui/card' -import { Field, FieldTitle } from './components/ui/field' -import { Input } from './components/ui/input' -import { Separator } from './components/ui/separator' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from './components/ui/select' -import { Switch } from './components/ui/switch' -import { Tabs, TabsList, TabsTrigger } from './components/ui/tabs' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './components/ui/tooltip' -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from './components/ui/context-menu' -import { StudioLanguageProvider, useStudioLanguage, type StudioLanguage } from './i18n' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from './components/ui/alert-dialog' - -import { - bodyPrimitiveTypes, - createBodyNode, - duplicateBodyNode, - MAX_BODY_NODES, - type BodyNode, -} from './body' -import { - applyAvatarEyeDefaults, - cloneAvatarBehavior, - createAvatar, - defaultAvatarEyes, - resolveAvatarBehavior, - type AvatarBehaviorLibrary, - type StudioAvatar, - type AvatarColors, - type AvatarEyeDefaults, -} from './avatars' -import { - expressionFields, - poseFromExpression, - renderAvatar, - renderBodyNodeEditor, - renderEyeEditor, - rotateBodyNodeAroundLocalAxis, - rotateExpressionAroundAxis, - rotateExpressionAroundCamera, - rotateExpressionWithArcball, - rotationRing, - translateBodyNodeInCameraPlane, - translateBodyNodeAlongLocalAxis, - type AvatarPose, - type Expression, - type Point3, -} from './geometry' -import { defaultExpression } from './presets' -import { - createSequence, - createSequenceStep, - duplicateSequence, - findExpressionIndex, - getSequenceSpring, - groupSequences, - readSequenceClock, - remapSequencesAfterExpressionDelete, - type AvatarSequence, - type SequenceStep, -} from './sequences' -import { ambientBodyOffset, applyAmbientMotion, hasAmbientMotion } from './ambientMotion' -import { surfaceLabels, surfacePresets, type SurfaceConfig } from './surfaces' -import { - createStudioDocumentStore, - loadStudioDocument, - parseImportedStudioDocument, - persistStudioDocument, - serializeStudioDocument, - type StudioDocument, - type StatePlaybackSelection, -} from './studioDocument' -import { - advancePlaybackTimeline, - beginPlayback, - createPlaybackTimeline, - pausePlaybackTimeline, - schedulePlaybackBlink, - schedulePlaybackStep, - stopPlaybackTimeline, -} from './playback' -import { - createRenderedColors, - createRenderedScene, - findBodyNodePath, - paintRenderedColors, - paintRenderedOffset, - paintRenderedScene, - type RenderedScene, - type RenderedColors, -} from './renderedScene' -import { - createRenderedRotationGizmo, - paintRenderedRotationGizmo, - type RenderedRotationGizmo, -} from './renderedRotationGizmo' -import { scaleEye, updateEyeDimension, updateEyePosition } from './expressionEditing' -import { resolveCanvasPreviewExpression, type CanvasPreviewTarget } from './canvasPreview' -import { - beginManipulation, - beginManipulationFromRenderedValue, - finishManipulation, - previewManipulation, - type ManipulationSession, -} from './manipulationSession' -import { - avatarExportFileName, - createAvatarExportPayload, - generateJavaScriptAvatarPackage, - generateReactAvatarPackage, -} from './exporter' -import { - serializeAvatarSnapshot, - snapshotFileName, - type SnapshotBackground, -} from './snapshotExporter' - -type Mode = 'manual' | 'expressions' | 'states' | 'export' -type ExportFormat = 'react' | 'javascript' -type SnapshotFormat = 'svg' | 'png' -type PlaybackStatus = 'playing' | 'paused' | 'stopped' - -const downloadBlob = (blob: Blob, fileName: string) => { - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = fileName - document.body.append(link) - link.click() - link.remove() - window.setTimeout(() => URL.revokeObjectURL(url), 0) -} -type Side = 'Left' | 'Right' -type NumericProps = { - label: string - value: number - min?: number - max?: number - step?: number - unit?: string - onChange: (value: number) => void - onActiveChange?: (active: boolean) => void -} -type Highlight = 'head' | 'left' | 'right' | 'both' | null - -const RETARGET_BLEND_MS = 120 -const INSPECTOR_FRAME_MS = 1000 / 24 -const AMBIENT_FRAME_MS = 1000 / 30 -const createExpressionId = () => `expression-${crypto.randomUUID()}` -const emptyBodyNodes: BodyNode[] = [] -const previewGeometryCache = new WeakMap< - Expression, - WeakMap< - SurfaceConfig, - WeakMap }> - > ->() - -const poseWithAvatarEyes = (expression: Expression, eyes: AvatarEyeDefaults) => - poseFromExpression(applyAvatarEyeDefaults(expression, eyes)) - -const getPreviewGeometry = ( - expression: Expression, - surface: SurfaceConfig, - bodyNodes: BodyNode[], - eyes: AvatarEyeDefaults = defaultAvatarEyes -) => { - let surfaceCache = previewGeometryCache.get(expression) - if (!surfaceCache) { - surfaceCache = new WeakMap() - previewGeometryCache.set(expression, surfaceCache) - } - let bodyCache = surfaceCache.get(surface) - if (!bodyCache) { - bodyCache = new WeakMap() - surfaceCache.set(surface, bodyCache) - } - const positionKey = JSON.stringify(eyes) - const cached = bodyCache.get(bodyNodes) - if (cached?.positionKey === positionKey) return cached.geometry - const geometry = renderAvatar(poseWithAvatarEyes(expression, eyes), surface, 1, { - includeWire: false, - bodyNodes, - }) - bodyCache.set(bodyNodes, { positionKey, geometry }) - return geometry -} - -const bounded = (value: number, min?: number, max?: number) => - Math.min(max ?? Infinity, Math.max(min ?? -Infinity, value)) - -const useEscapeToCancel = (cancel: () => void) => { - const cancelEvent = useEffectEvent(cancel) - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') cancelEvent() - } - window.addEventListener('keydown', onKeyDown) - return () => window.removeEventListener('keydown', onKeyDown) - }, []) -} - -const scaleSurface = ( - surface: SurfaceConfig, - size: number, - minimums: Pick -) => { - const currentSize = Math.max(surface.width, surface.height, surface.depth) || 1 - const factor = size / currentSize - return { - ...surface, - width: bounded(surface.width * factor, minimums.width, 300), - height: bounded(surface.height * factor, minimums.height, 300), - depth: bounded(surface.depth * factor, minimums.depth, 300), - } -} - -const formatSeconds = (milliseconds: number, language: StudioLanguage) => - `${new Intl.NumberFormat(language === 'fr' ? 'fr-FR' : language === 'zh-CN' ? 'zh-CN' : 'en-US', { - maximumFractionDigits: 1, - }).format(milliseconds / 1000)} s` - -const parseHexColor = (color: string) => { - const value = color.replace('#', '') - return [0, 2, 4].map(offset => Number.parseInt(value.slice(offset, offset + 2), 16)) -} - -const interpolateHexColor = (from: string, to: string, progress: number) => { - const fromChannels = parseHexColor(from) - const toChannels = parseHexColor(to) - return `#${fromChannels - .map((channel, index) => - Math.round(channel + (toChannels[index] - channel) * progress) - .toString(16) - .padStart(2, '0') - ) - .join('')}` -} - -const resolveColors = (expression: Expression, colors: AvatarColors): AvatarColors => ({ - body: expression.bodyColor ?? colors.body, - eyes: expression.eyeColor ?? colors.eyes, -}) - -function ColorField({ - label, - value, - onChange, -}: { - label: string - value: string - onChange: (value: string) => void -}) { - const { t } = useStudioLanguage() - const translatedLabel = t(label) - return ( - - {translatedLabel} - - onChange(event.currentTarget.value)} - /> - { - const next = event.currentTarget.value.toUpperCase() - if (/^#[0-9A-F]{6}$/.test(next)) onChange(next.toLowerCase()) - }} - onBlur={event => { - if (!/^#[0-9A-F]{6}$/.test(event.currentTarget.value.toUpperCase())) { - event.currentTarget.value = value.toUpperCase() - } - }} - /> - - - ) -} - -function AmbientMotionField({ - label, - value, - options, - onChange, -}: { - label: string - value: Motion - options: { value: Motion; label: string }[] - onChange: (value: Motion) => void -}) { - const { t } = useStudioLanguage() - return ( - - {t(label)} - - - ) -} - -function NumericField({ - label, - value, - min, - max, - step = 0.1, - unit = '', - onChange, - onActiveChange, -}: NumericProps) { - const { t } = useStudioLanguage() - const translatedLabel = t(label) - const dragRef = useRef<{ x: number; value: number } | null>(null) - const editingRef = useRef(false) - const [draftValue, setDraftValue] = useState(() => - String(Number(value.toFixed(step < 0.1 ? 2 : 1))) - ) - - useEffect(() => { - if (!editingRef.current) { - setDraftValue(String(Number(value.toFixed(step < 0.1 ? 2 : 1)))) - } - }, [step, value]) - - const commitDraftValue = (rawValue: string) => { - const parsedValue = Number(rawValue.replace(',', '.')) - const nextValue = - Number.isFinite(parsedValue) && rawValue.trim() !== '' - ? bounded(parsedValue, min, max) - : value - - setDraftValue(String(Number(nextValue.toFixed(step < 0.1 ? 2 : 1)))) - onChange(nextValue) - } - - const startScrub = (event: React.PointerEvent) => { - event.preventDefault() - onActiveChange?.(true) - dragRef.current = { x: event.clientX, value } - event.currentTarget.setPointerCapture(event.pointerId) - } - - const scrub = (event: React.PointerEvent) => { - if (!dragRef.current || !event.currentTarget.hasPointerCapture(event.pointerId)) return - const speed = event.shiftKey ? 10 : event.altKey ? 0.1 : 1 - const next = dragRef.current.value + (event.clientX - dragRef.current.x) * step * speed - onChange(bounded(next, min, max)) - } - - const stopScrub = () => { - dragRef.current = null - onActiveChange?.(false) - } - - return ( - - - - - ) -} - -function LinkButton({ - linked, - onClick, - label, -}: { - linked: boolean - onClick: () => void - label: string -}) { - const { t } = useStudioLanguage() - return ( - - ) -} - -function RotationGizmo({ - expression, - rendered, - onChange, - onActiveChange, - onReset, -}: { - expression: Expression - rendered: RenderedRotationGizmo - onChange: (next: Expression) => void - onActiveChange: (active: boolean) => void - onReset: () => void -}) { - const { t } = useStudioLanguage() - const drag = useRef< - | { - type: 'axis' - axis: 'x' | 'y' | 'z' - startPoint: readonly [number, number] - tangent: readonly [number, number] - expression: Expression - } - | { type: 'view'; startAngle: number; expression: Expression } - | null - >(null) - const pose = poseFromExpression(expression) - const rings = { - x: rotationRing(pose, 'x'), - y: rotationRing(pose, 'y'), - z: rotationRing(pose, 'z'), - } - const toLocal = (event: React.PointerEvent): readonly [number, number] => { - const rectangle = event.currentTarget.ownerSVGElement!.getBoundingClientRect() - return [ - ((event.clientX - rectangle.left) / rectangle.width) * 86 - 43, - ((event.clientY - rectangle.top) / rectangle.height) * 86 - 43, - ] - } - const unitVector = (from: Point3, to: Point3): readonly [number, number] => { - const x = to[0] - from[0] - const y = to[1] - from[1] - const length = Math.hypot(x, y) || 1 - return [x / length, y / length] - } - const startAxis = (axis: 'x' | 'y' | 'z', event: React.PointerEvent) => { - event.stopPropagation() - onActiveChange(true) - const point = toLocal(event) - const ring = rings[axis] - let closestIndex = 0 - let closestDistance = Number.POSITIVE_INFINITY - ring.slice(0, -1).forEach((ringPoint, index) => { - const distance = Math.hypot(ringPoint[0] - point[0], ringPoint[1] - point[1]) - if (distance < closestDistance) { - closestIndex = index - closestDistance = distance - } - }) - const previous = ring[(closestIndex - 1 + ring.length - 1) % (ring.length - 1)] - const next = ring[(closestIndex + 1) % (ring.length - 1)] - drag.current = { - type: 'axis', - axis, - startPoint: point, - tangent: unitVector(previous, next), - expression, - } - event.currentTarget.setPointerCapture(event.pointerId) - } - const startView = (event: React.PointerEvent) => { - event.stopPropagation() - onActiveChange(true) - const point = toLocal(event) - drag.current = { type: 'view', startAngle: Math.atan2(point[1], point[0]), expression } - event.currentTarget.setPointerCapture(event.pointerId) - } - const move = (event: React.PointerEvent) => { - if (!drag.current) return - const point = toLocal(event) - if (drag.current.type === 'view') { - const currentAngle = Math.atan2(point[1], point[0]) - const delta = Math.atan2( - Math.sin(currentAngle - drag.current.startAngle), - Math.cos(currentAngle - drag.current.startAngle) - ) - onChange(rotateExpressionAroundCamera(drag.current.expression, delta)) - return - } - const signedDistance = - (point[0] - drag.current.startPoint[0]) * drag.current.tangent[0] + - (point[1] - drag.current.startPoint[1]) * drag.current.tangent[1] - onChange( - rotateExpressionAroundAxis(drag.current.expression, drag.current.axis, signedDistance * 1.5) - ) - } - const stop = () => { - drag.current = null - onActiveChange(false) - } - const cancel = () => { - if (drag.current) onChange(drag.current.expression) - stop() - } - useEscapeToCancel(cancel) - return ( -
- - - startAxis('y', event)} - onPointerMove={move} - onPointerUp={stop} - onPointerCancel={cancel} - /> - startAxis('x', event)} - onPointerMove={move} - onPointerUp={stop} - onPointerCancel={cancel} - /> - startAxis('z', event)} - onPointerMove={move} - onPointerUp={stop} - onPointerCancel={cancel} - /> - - -
- ) -} - -type TransformAxis = 'x' | 'y' | 'z' - -function BodyNodeGizmo({ - svgRef, - pose, - node, - onPreview, - onCommit, -}: { - svgRef: React.RefObject - pose: AvatarPose - node: BodyNode - onPreview: (next: BodyNode) => void - onCommit: (next: BodyNode) => void -}) { - const { t } = useStudioLanguage() - const geometry = renderBodyNodeEditor(pose, node) - const [activeControl, setActiveControl] = useState< - { mode: 'translate' | 'rotate'; axis: TransformAxis } | { mode: 'plane' } | undefined - >(undefined) - const drag = useRef< - | { - mode: 'translate' | 'rotate' - axis: TransformAxis - startPoint: readonly [number, number] - direction: readonly [number, number] - scale: number - node: BodyNode - } - | { - mode: 'plane' - startPoint: readonly [number, number] - node: BodyNode - } - | undefined - >(undefined) - const latestNode = useRef(node) - const manipulation = useRef | null>(null) - const previewFrame = useRef(undefined) - const axes: TransformAxis[] = ['x', 'y', 'z'] - const toSvg = (event: React.PointerEvent): readonly [number, number] => { - const rectangle = svgRef.current!.getBoundingClientRect() - return [ - ((event.clientX - rectangle.left) / rectangle.width) * 300 - 150, - ((event.clientY - rectangle.top) / rectangle.height) * 300 - 150, - ] - } - const directionBetween = (from: Point3, to: Point3): readonly [number, number] => { - const x = to[0] - from[0] - const y = to[1] - from[1] - const length = Math.hypot(x, y) || 1 - return [x / length, y / length] - } - const ringPath = (points: Point3[]) => - `M${points.map(point => `${point[0]} ${point[1]}`).join('L')}Z` - const startTranslate = (axis: TransformAxis, event: React.PointerEvent) => { - event.stopPropagation() - const endpoint = geometry.axes[axis] - const length = Math.max( - Math.hypot(endpoint[0] - geometry.center[0], endpoint[1] - geometry.center[1]), - 1 - ) - drag.current = { - mode: 'translate', - axis, - startPoint: toSvg(event), - direction: directionBetween(geometry.center, endpoint), - scale: 34 / length, - node, - } - setActiveControl({ mode: 'translate', axis }) - latestNode.current = node - manipulation.current = beginManipulation(node) - event.currentTarget.setPointerCapture(event.pointerId) - } - const startRotate = (axis: TransformAxis, event: React.PointerEvent) => { - event.stopPropagation() - const point = toSvg(event) - const ring = geometry.rings[axis] - let closestIndex = 0 - let closestDistance = Infinity - ring.slice(0, -1).forEach((ringPoint, index) => { - const distance = Math.hypot(ringPoint[0] - point[0], ringPoint[1] - point[1]) - if (distance < closestDistance) { - closestIndex = index - closestDistance = distance - } - }) - const previous = ring[(closestIndex - 1 + ring.length - 1) % (ring.length - 1)] - const next = ring[(closestIndex + 1) % (ring.length - 1)] - const radius = Math.max( - Math.hypot( - ring[closestIndex][0] - geometry.center[0], - ring[closestIndex][1] - geometry.center[1] - ), - 8 - ) - drag.current = { - mode: 'rotate', - axis, - startPoint: point, - direction: directionBetween(previous, next), - scale: 180 / Math.PI / radius, - node, - } - setActiveControl({ mode: 'rotate', axis }) - latestNode.current = node - manipulation.current = beginManipulation(node) - event.currentTarget.setPointerCapture(event.pointerId) - } - const startPlaneTranslate = (event: React.PointerEvent) => { - event.stopPropagation() - drag.current = { mode: 'plane', startPoint: toSvg(event), node } - setActiveControl({ mode: 'plane' }) - latestNode.current = node - manipulation.current = beginManipulation(node) - event.currentTarget.setPointerCapture(event.pointerId) - } - const move = (event: React.PointerEvent) => { - if (!drag.current) return - const interaction = drag.current - const point = toSvg(event) - const deltaX = point[0] - interaction.startPoint[0] - const deltaY = point[1] - interaction.startPoint[1] - const next = (() => { - if (interaction.mode === 'plane') { - return translateBodyNodeInCameraPlane(interaction.node, pose, deltaX, deltaY) - } - const delta = - (deltaX * interaction.direction[0] + deltaY * interaction.direction[1]) * interaction.scale - return interaction.mode === 'translate' - ? translateBodyNodeAlongLocalAxis(interaction.node, interaction.axis, delta) - : rotateBodyNodeAroundLocalAxis(interaction.node, interaction.axis, delta) - })() - latestNode.current = next - if (manipulation.current) { - previewManipulation(manipulation.current, next) - } - if (previewFrame.current !== undefined) return - previewFrame.current = requestAnimationFrame(() => { - previewFrame.current = undefined - onPreview(latestNode.current) - }) - } - const stop = () => { - if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current) - previewFrame.current = undefined - if (manipulation.current) { - finishManipulation(manipulation.current, 'commit', { preview: onPreview, commit: onCommit }) - } - manipulation.current = null - drag.current = undefined - setActiveControl(undefined) - } - const cancel = () => { - if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current) - previewFrame.current = undefined - if (manipulation.current) { - finishManipulation(manipulation.current, 'cancel', { preview: onPreview, commit: onCommit }) - } - manipulation.current = null - drag.current = undefined - setActiveControl(undefined) - } - useEscapeToCancel(cancel) - const resetTransform = (event: React.MouseEvent) => { - event.stopPropagation() - const resetNode: BodyNode = { ...node, position: [0, 0, 0], rotation: [0, 0, 0] } - latestNode.current = resetNode - onCommit(resetNode) - } - - useEffect( - () => () => { - if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current) - }, - [] - ) - - return ( - - {axes.map(axis => ( - - startRotate(axis, event)} - onPointerMove={move} - onPointerUp={stop} - onPointerCancel={cancel} - /> - - - ))} - {axes.map(axis => ( - - startTranslate(axis, event)} - onPointerMove={move} - onPointerUp={stop} - onPointerCancel={cancel} - /> - - - - {axis.toUpperCase()} - - - ))} - - - { - if (event.key !== 'Enter' && event.key !== ' ') return - event.preventDefault() - const resetNode: BodyNode = { ...node, position: [0, 0, 0], rotation: [0, 0, 0] } - latestNode.current = resetNode - onCommit(resetNode) - }} - > - - ↺ - - - ) -} - -function AvatarCanvas({ - expression, - avatarEyes, - surface, - scene, - rotationGizmo, - showWire, - bodyEditing, - selectedBodyNodeId, - selectedBodyNode, - selectedSide, - linked, - highlight, - onHighlightChange, - onBodyNodeSelect, - onBodyNodePreview, - onBodyNodeChange, - onEyeSelect, - onPreview, - onChange, - onReset, - onEyeChange, - playback, - onManipulationStart, -}: { - expression: Expression - avatarEyes: AvatarEyeDefaults - surface: SurfaceConfig - scene: RenderedScene - rotationGizmo: RenderedRotationGizmo - showWire: boolean - bodyEditing: boolean - selectedBodyNodeId: 'primary' | string | null - selectedBodyNode: BodyNode | null - selectedSide: -1 | 1 | null - linked: { width: boolean; height: boolean; size: boolean } - highlight: Highlight - onHighlightChange: (highlight: Highlight) => void - onBodyNodeSelect: (id: 'primary' | string | null) => void - onBodyNodePreview: (next: BodyNode) => void - onBodyNodeChange: (next: BodyNode) => void - onEyeSelect: (side: -1 | 1) => void - onPreview: (next: Expression, target: CanvasPreviewTarget) => void - onChange: (next: Expression) => void - onReset: (next: Expression) => void - onEyeChange?: (next: Expression) => void - playback: { name: string; status: Exclude } | null - onManipulationStart: () => Expression -}) { - const { t } = useStudioLanguage() - const { - wirePaths, - backPaths, - frontPaths, - backNodeIds, - frontNodeIds, - headPath, - leftPath, - rightPath, - leftOpacity, - rightOpacity, - offsetX, - offsetY, - } = scene - const svgRef = useRef(null) - const [activeDragType, setActiveDragType] = useState< - 'arcball' | 'width' | 'height' | 'size' | 'spacing' | 'rotate' | null - >(null) - const drag = useRef< - | { - type: 'arcball' - startPoint: readonly [number, number] - expression: Expression - } - | { - type: 'width' | 'height' | 'size' | 'spacing' | 'rotate' - side: -1 | 1 - startPoint: readonly [number, number] - expression: Expression - center: Point3 - widthAxis: readonly [number, number] - heightAxis: readonly [number, number] - spacingAxis: readonly [number, number] - startPointerAngle: number - startDistance: number - } - | null - >(null) - const canvasManipulation = useRef | null>(null) - const editor = - selectedSide === null - ? null - : renderEyeEditor(poseWithAvatarEyes(expression, avatarEyes), surface, selectedSide) - const selectedBodyPath = (() => { - if (!bodyEditing || !selectedBodyNodeId) return null - return findBodyNodePath(scene, selectedBodyNodeId) - })() - - const toSvg = (event: React.PointerEvent): readonly [number, number] => { - const rectangle = svgRef.current!.getBoundingClientRect() - return [ - ((event.clientX - rectangle.left) / rectangle.width) * 300 - 150, - ((event.clientY - rectangle.top) / rectangle.height) * 300 - 150, - ] - } - const unitVector = (from: Point3, to: Point3): readonly [number, number] => { - const x = to[0] - from[0] - const y = to[1] - from[1] - const length = Math.hypot(x, y) || 1 - return [x / length, y / length] - } - const startDrag = (event: React.PointerEvent) => { - const session = beginManipulationFromRenderedValue(onManipulationStart) - const renderedExpression = session.initial - onBodyNodeSelect('primary') - onHighlightChange('head') - drag.current = { - type: 'arcball', - startPoint: toSvg(event), - expression: renderedExpression, - } - canvasManipulation.current = session - setActiveDragType('arcball') - svgRef.current!.setPointerCapture(event.pointerId) - } - const selectBodyPath = ( - event: React.PointerEvent, - nodeId: string | null | undefined - ) => { - if (!nodeId || !bodyEditing) { - startDrag(event) - return - } - event.stopPropagation() - onBodyNodeSelect(nodeId) - } - const selectEye = (side: -1 | 1, event: React.PointerEvent) => { - event.stopPropagation() - onBodyNodeSelect(null) - onEyeSelect(side) - } - const startHandle = ( - type: 'width' | 'height' | 'size' | 'spacing' | 'rotate', - event: React.PointerEvent - ) => { - event.stopPropagation() - if (selectedSide === null || !editor) return - const session = beginManipulationFromRenderedValue(onManipulationStart) - const renderedExpression = session.initial - onHighlightChange(selectedSide < 0 ? 'left' : 'right') - const point = toSvg(event) - const editableExpression = bodyEditing - ? applyAvatarEyeDefaults(renderedExpression, avatarEyes) - : renderedExpression - const livePose = poseWithAvatarEyes(renderedExpression, avatarEyes) - const liveEditor = renderEyeEditor(livePose, surface, selectedSide) - const leftEditor = renderEyeEditor(livePose, surface, -1) - const rightEditor = renderEyeEditor(livePose, surface, 1) - drag.current = { - type, - side: selectedSide, - startPoint: point, - expression: editableExpression, - center: liveEditor.center, - widthAxis: unitVector(liveEditor.center, liveEditor.widthHandle), - heightAxis: unitVector(liveEditor.center, liveEditor.heightHandle), - spacingAxis: unitVector(leftEditor.center, rightEditor.center), - startPointerAngle: Math.atan2( - point[1] - liveEditor.center[1], - point[0] - liveEditor.center[0] - ), - startDistance: Math.max( - Math.hypot(point[0] - liveEditor.center[0], point[1] - liveEditor.center[1]), - 1 - ), - } - canvasManipulation.current = beginManipulation(editableExpression) - setActiveDragType(type) - svgRef.current!.setPointerCapture(event.pointerId) - } - const move = (event: React.PointerEvent) => { - if (!drag.current) return - const point = toSvg(event) - if (drag.current.type === 'arcball') { - const next = rotateExpressionWithArcball( - drag.current.expression, - drag.current.startPoint, - point - ) - if (canvasManipulation.current) { - previewManipulation(canvasManipulation.current, next, value => onPreview(value, 'head')) - } - return - } - const interaction = drag.current - const suffix = interaction.side < 0 ? 'Left' : 'Right' - const deltaX = point[0] - interaction.startPoint[0] - const deltaY = point[1] - interaction.startPoint[1] - const along = (axis: readonly [number, number]) => deltaX * axis[0] + deltaY * axis[1] - let next = { ...interaction.expression } - const side = interaction.side < 0 ? 'Left' : 'Right' - if (interaction.type === 'width') { - const value = bounded( - interaction.expression[`width${suffix}`] + along(interaction.widthAxis) * 2, - 10, - 100 - ) - next = updateEyeDimension(interaction.expression, side, 'width', value, linked.width) - } else if (interaction.type === 'height') { - const value = bounded( - interaction.expression[`height${suffix}`] + along(interaction.heightAxis) * 2, - 10, - 100 - ) - next = updateEyeDimension(interaction.expression, side, 'height', value, linked.height) - } else if (interaction.type === 'size') { - const distance = Math.hypot( - point[0] - interaction.center[0], - point[1] - interaction.center[1] - ) - const factor = distance / interaction.startDistance - const targetSize = - Math.max( - interaction.expression[`width${suffix}`], - interaction.expression[`height${suffix}`] - ) * factor - next = scaleEye(interaction.expression, side, targetSize, linked.size) - } else if (interaction.type === 'spacing') { - const startSpacing = interaction.expression.spacing - const spacing = bounded(startSpacing + along(interaction.spacingAxis), 0, 150) - next.spacing = spacing - } else { - const currentAngle = Math.atan2( - point[1] - interaction.center[1], - point[0] - interaction.center[0] - ) - const deltaAngle = Math.atan2( - Math.sin(currentAngle - interaction.startPointerAngle), - Math.cos(currentAngle - interaction.startPointerAngle) - ) - next[interaction.side < 0 ? 'leftAngle' : 'rightAngle'] = - interaction.expression[interaction.side < 0 ? 'leftAngle' : 'rightAngle'] + - (deltaAngle * 180) / Math.PI - } - if (canvasManipulation.current) { - previewManipulation(canvasManipulation.current, next, value => onPreview(value, 'eyes')) - } - } - const commitDrag = () => { - const interaction = drag.current - const session = canvasManipulation.current - if (interaction && session) { - finishManipulation(session, 'commit', { - preview: value => onPreview(value, interaction.type === 'arcball' ? 'head' : 'eyes'), - commit: interaction.type === 'arcball' ? onChange : (onEyeChange ?? onChange), - }) - } - canvasManipulation.current = null - drag.current = null - setActiveDragType(null) - onHighlightChange(null) - } - const cancelDrag = () => { - const interaction = drag.current - const session = canvasManipulation.current - if (interaction && session) { - finishManipulation(session, 'cancel', { - preview: value => onPreview(value, interaction.type === 'arcball' ? 'head' : 'eyes'), - commit: onChange, - }) - } - canvasManipulation.current = null - drag.current = null - setActiveDragType(null) - onHighlightChange(null) - } - useEscapeToCancel(cancelDrag) - return ( -
- {playback && ( - - - - )} - - - - - - - - {backPaths.map((pathValue, index) => ( - selectBodyPath(event, backNodeIds.current[index])} - /> - ))} - { - onBodyNodeSelect('primary') - startDrag(event) - }} - /> - - {(showWire || highlight === 'head') && - wirePaths.map((pathValue, index) => ( - - ))} - selectEye(-1, event)} - /> - selectEye(1, event)} - /> - - {frontPaths.map((pathValue, index) => ( - selectBodyPath(event, frontNodeIds.current[index])} - /> - ))} - - {selectedBodyPath && ( - - )} - {bodyEditing && selectedBodyNode && ( - - )} - {editor?.visible && ( - - {activeDragType !== null && activeDragType !== 'arcball' && ( - - )} - - - - - - - - - - - )} - - onHighlightChange(active ? 'head' : null)} - onReset={() => onReset({ ...expression, headX: 0, headY: 0, headZ: 0 })} - /> -
- X Y Z -
-
- ) -} - -type EyeHandle = 'width' | 'height' | 'size' | 'spacing' | 'rotate' -type HandleStart = (type: EyeHandle, event: React.PointerEvent) => void - -function EditorCircle({ - point, - label, - type, - onStart, -}: { - point: Point3 - label: string - type: EyeHandle - onStart: HandleStart -}) { - return ( - onStart(type, event)} - > - - - {label} - - - ) -} - -function EditorSquare({ - point, - label, - type, - onStart, -}: { - point: Point3 - label: string - type: EyeHandle - onStart: HandleStart -}) { - return ( - onStart(type, event)} - > - - - {label} - - - ) -} - -function SurfaceThumbnail({ surface }: { surface: SurfaceConfig }) { - const geometry = getPreviewGeometry(defaultExpression, surface, emptyBodyNodes) - return ( - - ) -} - -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 ( - - ) -} - -function ExpressionCard({ - expression, - index, - active, - surface, - bodyNodes, - colors, - avatarEyes, - previewId, - onSelect, - onEdit, - onDuplicate, - onDelete, - draggable, - onDragStart, - onDragEnter, - onDragOver, - onDrop, - onDragEnd, -}: { - expression: Expression - index: number - active: boolean - surface: SurfaceConfig - bodyNodes: BodyNode[] - colors: AvatarColors - avatarEyes: AvatarEyeDefaults - previewId: string - onSelect: () => void - onEdit?: () => void - onDuplicate?: () => void - onDelete?: () => void - draggable?: boolean - onDragStart?: (event: React.DragEvent) => void - onDragEnter?: () => void - onDragOver?: (event: React.DragEvent) => void - onDrop?: (event: React.DragEvent) => void - onDragEnd?: () => void -}) { - const { t } = useStudioLanguage() - const card = ( - - ) - if (!onEdit) return card - return ( - - - - - {t('Modifier')} - - {onDuplicate && ( - - {t('Dupliquer')} - - )} - {onDelete && ( - <> - - - {t('Supprimer')} - - - )} - - - ) -} - -function ExpressionWorkspace({ - editing, - avatarColors, - backButtonRef, - onChange, - onCancel, - onSave, - onDuplicate, - onDelete, -}: { - editing: { index: number | null; draft: Expression } - avatarColors: AvatarColors - backButtonRef: RefObject - onChange: (draft: Expression) => void - onCancel: () => void - onSave: () => void - onDuplicate: () => void - onDelete: () => void -}) { - const { t } = useStudioLanguage() - const [linked, setLinked] = useState({ - width: true, - height: true, - size: true, - rotation: true, - }) - const update = (changes: Partial) => onChange({ ...editing.draft, ...changes }) - const updateDimension = (side: Side, dimension: 'width' | 'height', value: number) => { - onChange(updateEyeDimension(editing.draft, side, dimension, value, linked[dimension])) - } - const updateSize = (side: Side, value: number) => { - onChange(scaleEye(editing.draft, side, value, linked.size)) - } - const updateRotation = (side: Side, value: number) => { - onChange({ - ...editing.draft, - [side === 'Left' ? 'leftAngle' : 'rightAngle']: value, - ...(linked.rotation ? { [side === 'Left' ? 'rightAngle' : 'leftAngle']: -value } : {}), - }) - } - - return ( - <> -
- -
-

{t('Preset en mémoire')}

-

- {editing.index === null - ? t('Nouvelle expression') - : t(`Modifier l’expression ${String(editing.index).padStart(2, '0')}`)} -

-

{t('L’avatar à gauche affiche cette expression en direct.')}

-
-
-
-
- - -

{t('Couleur du corps')}

- update({ bodyColor })} - /> - {editing.draft.bodyColor && ( - - )} -
- -

{t('Mouvement perpétuel')}

- update({ bodyMotion })} - /> -

- {t('Ajoute une légère présence ou un tremblement continu au corps.')} -

-
- -

{t('Rotation de la tête')}

- {(['headX', 'headY', 'headZ'] as const).map(field => ( - update({ [field]: value })} - /> - ))} -
-
- - -

{t('Couleur des yeux')}

- update({ eyeColor })} - /> - {editing.draft.eyeColor && ( - - )} -
- -

{t('Mouvement perpétuel')}

- update({ eyeMotion })} - /> -

- {t('Anime le regard par petites saccades naturelles ou par tremblement.')} -

-
- {(['width', 'height', 'size'] as const).map(dimension => ( - -
-

- {t( - { width: 'Largeur', height: 'Hauteur', size: 'Taille proportionnelle' }[ - dimension - ] - )} -

- - setLinked(current => ({ ...current, [dimension]: !current[dimension] })) - } - /> -
-
- {(['Left', 'Right'] as Side[]).map(side => { - const width = editing.draft[`width${side}`] - const height = editing.draft[`height${side}`] - const value = - dimension === 'width' - ? width - : dimension === 'height' - ? height - : Math.max(width, height) - return ( - - dimension === 'size' - ? updateSize(side, next) - : updateDimension(side, dimension, next) - } - /> - ) - })} -
-
- ))} - -

{t('Position et espacement')}

-
- {(['Left', 'Right'] as Side[]).map(side => ( -
-

{t(side === 'Left' ? 'Œil gauche' : 'Œil droit')}

- update({ [`positionX${side}`]: value })} - /> - update({ [`positionY${side}`]: value })} - /> -
- ))} -
-
- update({ spacing: value })} - /> -
-
- -
-

{t('Rotation locale')}

- - setLinked(current => ({ ...current, rotation: !current.rotation })) - } - /> -
-
- updateRotation('Left', value)} - /> - updateRotation('Right', value)} - /> -
-
-
- - - update({ perspective: value })} - /> - - -
-
-
-
- {editing.index !== null && ( - - )} - -
-
- -
-
- - ) -} - -function SequenceWorkspace({ - editing, - expressions, - surface, - bodyNodes, - colors, - avatarEyes, - selectedStepId, - backButtonRef, - reduceMotion, - onSelectedStepChange, - onChange, - onPreviewStep, - onEditExpression, - onPlay, - onPause, - onStop, - playing, - active, - onCancel, - onSave, - onDuplicate, - onDelete, -}: { - editing: { sourceId: string | null; draft: AvatarSequence } - expressions: Expression[] - surface: SurfaceConfig - bodyNodes: BodyNode[] - colors: AvatarColors - avatarEyes: AvatarEyeDefaults - selectedStepId: string | null - backButtonRef: RefObject - reduceMotion: boolean - onSelectedStepChange: (id: string | null) => void - onChange: (draft: AvatarSequence) => void - onPreviewStep: (step: SequenceStep) => void - onEditExpression: (index: number, expression: Expression) => void - onPlay: () => void - onPause: () => void - onStop: () => void - playing: boolean - active: boolean - onCancel: () => void - onSave: () => void - onDuplicate: () => void - onDelete: () => void -}) { - const { t } = useStudioLanguage() - const draggedStepId = useRef(null) - const selectedStep = - editing.draft.steps.find(step => step.id === selectedStepId) ?? editing.draft.steps[0] - - const updateStep = (changes: Partial) => { - if (!selectedStep) return - onChange({ - ...editing.draft, - steps: editing.draft.steps.map(step => - step.id === selectedStep.id ? { ...step, ...changes } : step - ), - }) - } - - const moveStep = (targetId: string) => { - const draggedId = draggedStepId.current - if (!draggedId || draggedId === targetId) return - const dragged = editing.draft.steps.find(step => step.id === draggedId) - const targetIndex = editing.draft.steps.findIndex(step => step.id === targetId) - if (!dragged || targetIndex < 0) return - const next = editing.draft.steps.filter(step => step.id !== draggedId) - next.splice(targetIndex, 0, dragged) - onChange({ ...editing.draft, steps: next }) - } - - return ( - <> -
- -
-

{t('Éditeur d’animation')}

-

{editing.sourceId ? t('Modifier l’animation') : t('Nouvelle animation')}

-

{t('Compose les expressions, leur cadence et les clignements de cette animation.')}

-
-
- - {active && ( - - )} -
-
- -
- - - - {t('Nom')} - onChange({ ...editing.draft, name: event.currentTarget.value })} - /> - - - {t('Catégorie')} - - - - {t('Description')} - - onChange({ ...editing.draft, description: event.currentTarget.value }) - } - /> - - - {t('Mode de lecture')} - - - - - - - - {editing.draft.steps.length ? ( -
- {editing.draft.steps.map((step, position) => { - const expressionIndex = findExpressionIndex(expressions, step.expressionId) - const preset = expressions[expressionIndex] - if (!preset) return null - const card = ( - - ) - return ( - { - draggedStepId.current = step.id - onSelectedStepChange(step.id) - }} - onDragEnter={() => moveStep(step.id)} - onDragOver={event => event.preventDefault()} - onDragEnd={() => { - draggedStepId.current = null - }} - > - - - - onEditExpression(expressionIndex, preset)} - > - {t('Modifier')} - - - - - ) - })} -
- ) : ( -

{t('Ajoute une expression pour commencer.')}

- )} - - {selectedStep ? ( -
-
-
-

{t('Étape sélectionnée')}

-

{t('Durée visible avant de passer à l’expression suivante.')}

-
- -
- updateStep({ holdMs })} - /> - updateStep({ transitionMs })} - /> - - {t('Transition')} - - -
- ) : null} -
- - - -
- {expressions.map((preset, index) => ( - - ))} -
-
-
- - - -
- {t('Activer les clignements')} - - onChange({ - ...editing.draft, - blink: { ...editing.draft.blink, enabled }, - }) - } - /> -
- - onChange({ - ...editing.draft, - blink: { ...editing.draft.blink, initialDelayMs }, - }) - } - /> -
- - onChange({ - ...editing.draft, - blink: { - ...editing.draft.blink, - minIntervalMs, - maxIntervalMs: Math.max(minIntervalMs, editing.draft.blink.maxIntervalMs), - }, - }) - } - /> - - onChange({ - ...editing.draft, - blink: { ...editing.draft.blink, maxIntervalMs }, - }) - } - /> -
- - onChange({ - ...editing.draft, - blink: { ...editing.draft.blink, durationMs }, - }) - } - /> -
-
-
- -
-
- {editing.sourceId && ( - - )} - -
- -
- - ) -} - -function StudioApp() { - const { language, setLanguage, t } = useStudioLanguage() - const [mode, setMode] = useState('manual') - const [initialDocument] = useState(loadStudioDocument) - const [documentStore] = useState(() => createStudioDocumentStore(initialDocument)) - const initialLibrary = initialDocument.library - const [avatars, setAvatars] = useState(initialLibrary.avatars) - const [activeAvatarId, setActiveAvatarId] = useState(initialLibrary.activeAvatarId) - const initialAvatar = - initialLibrary.avatars.find(avatar => avatar.id === initialLibrary.activeAvatarId) ?? - initialLibrary.avatars[0] - const [baseBehavior] = useState(() => ({ - expressions: initialDocument.expressions, - sequences: initialDocument.sequences, - })) - const initialBehavior = resolveAvatarBehavior(initialAvatar, baseBehavior) - const [surface, setSurface] = useState(initialAvatar.body.primary) - const [bodyNodes, setBodyNodes] = useState(initialAvatar.body.nodes) - const [selectedBodyNodeId, setSelectedBodyNodeId] = useState<'primary' | string | null>('primary') - const [selectedEyeSide, setSelectedEyeSide] = useState<-1 | 1 | null>(null) - const [expressions, setExpressions] = useState(initialBehavior.expressions) - const [sequences, setSequences] = useState(initialBehavior.sequences) - const [exportFormat, setExportFormat] = useState('react') - const [exportAnimationIds, setExportAnimationIds] = useState(() => - initialBehavior.sequences.map(animation => animation.id) - ) - const [snapshotBackground, setSnapshotBackground] = useState('transparent') - const [snapshotColorFrom, setSnapshotColorFrom] = useState('#F5F7FC') - const [snapshotColorTo, setSnapshotColorTo] = useState('#C9D5FF') - const [snapshotSize, setSnapshotSize] = useState('1024') - const [snapshotFormat, setSnapshotFormat] = useState('png') - const [photoFlash, setPhotoFlash] = useState(0) - const initialStatePlayback = initialDocument.playback - const updateStudioLibrary = (library: typeof initialDocument.library) => - documentStore.update({ library }) - const persistStatePlayback = (playback: StatePlaybackSelection) => - documentStore.update({ playback }) - const [bodyEditing, setBodyEditing] = useState(false) - const modeRef = useRef(mode) - useEffect(() => { - modeRef.current = mode - }, [mode]) - const avatarEditSnapshot = useRef<{ - avatars: StudioAvatar[] - activeAvatarId: string - } | null>(null) - const workspaceBackButtonRef = useRef(null) - const [focusAvatarName, setFocusAvatarName] = useState(false) - const initialExpression = expressions[0] ?? defaultExpression - const [expression, setExpression] = useState({ ...initialExpression }) - const initialDisplayColors = resolveColors(initialExpression, initialAvatar.colors) - const [renderedColors] = useState(() => createRenderedColors(initialDisplayColors)) - const setDisplayColors = (next: AvatarColors) => { - paintRenderedColors(renderedColors, next) - } - const [deleteAvatarOpen, setDeleteAvatarOpen] = useState(false) - const [deleteExpressionOpen, setDeleteExpressionOpen] = useState(false) - const [deleteSequenceOpen, setDeleteSequenceOpen] = useState(false) - const [pendingProjectImport, setPendingProjectImport] = useState<{ - document: StudioDocument - fileName: string - } | null>(null) - const [projectImportError, setProjectImportError] = useState(null) - const projectImportRef = useRef(null) - const [statePlayerExpanded, setStatePlayerExpanded] = useState(false) - const [activeExpression, setActiveExpression] = useState(null) - const [editing, setEditing] = useState<{ index: number | null; draft: Expression } | null>(null) - const [showWire, setShowWire] = useState(false) - const [springSpeed, setSpringSpeed] = useState(7) - const [linked, setLinked] = useState({ - width: true, - height: true, - size: true, - position: true, - rotation: true, - }) - const [highlight, setHighlight] = useState(null) - const [selectedState, setSelectedState] = useState(() => - sequences.some(sequence => sequence.id === initialStatePlayback.stateId) - ? initialStatePlayback.stateId! - : sequences.some(sequence => sequence.id === 'idle') - ? 'idle' - : (sequences[0]?.id ?? '') - ) - const [activeState, setActiveState] = useState(null) - const [statePlaying, setStatePlaying] = useState(false) - const [playbackStatus, setPlaybackStatus] = useState(() => - initialStatePlayback.stateId === null ? 'stopped' : 'paused' - ) - const [playbackVisual, setPlaybackVisual] = useState({ - position: null as number | null, - run: 0, - durationMs: 0, - }) - const [sequenceEditing, setSequenceEditing] = useState<{ - sourceId: string | null - draft: AvatarSequence - } | null>(null) - const [selectedSequenceStepId, setSelectedSequenceStepId] = useState(null) - const stateTimer = useRef | null>(null) - const activeSequenceRef = useRef(null) - const editorStateSnapshot = useRef<{ - stateId: string - playing: boolean - expression: Expression - } | null>(null) - const initialStatePlaybackApplied = useRef(false) - const blinkTimer = useRef | null>(null) - const [initialPlaybackTimeline] = useState(createPlaybackTimeline) - const playbackTimeline = useRef(initialPlaybackTimeline) - const reduceMotion = useReducedMotion() - - const avatarsRef = useRef(avatars) - const expressionsRef = useRef(expressions) - const sequencesRef = useRef(sequences) - const draggedAvatarId = useRef(null) - const avatarDragOrigin = useRef(null) - const avatarDragPreview = useRef(avatars) - const [draggingAvatarId, setDraggingAvatarId] = useState(null) - const draggedExpressionId = useRef(null) - const expressionDragOrigin = useRef(null) - const expressionDragPreview = useRef(expressions) - const [draggingExpressionId, setDraggingExpressionId] = useState(null) - const draggedStateId = useRef(null) - const stateDragOrigin = useRef(null) - const stateDragPreview = useRef(sequences) - const [draggingStateId, setDraggingStateId] = useState(null) - const activeAvatarIdRef = useRef(activeAvatarId) - const surfaceRef = useRef(surface) - const bodyNodesRef = useRef(bodyNodes) - const showWireRef = useRef(showWire) - const highlightRef = useRef(highlight) - const persistActiveBehavior = ( - nextExpressions: Expression[], - nextSequences: AvatarSequence[] - ) => { - const behavior = cloneAvatarBehavior({ - expressions: nextExpressions, - sequences: nextSequences, - }) - const nextAvatars = avatarsRef.current.map(avatar => - avatar.id === activeAvatarIdRef.current ? { ...avatar, behavior } : avatar - ) - avatarsRef.current = nextAvatars - setAvatars(nextAvatars) - updateStudioLibrary({ activeAvatarId: activeAvatarIdRef.current, avatars: nextAvatars }) - } - const updateStudioExpressions = (nextExpressions: Expression[]) => { - expressionsRef.current = nextExpressions - persistActiveBehavior(nextExpressions, sequencesRef.current) - } - const updateStudioSequences = (nextSequences: AvatarSequence[]) => { - sequencesRef.current = nextSequences - persistActiveBehavior(expressionsRef.current, nextSequences) - } - const [initialRender] = useState(() => { - const pose = poseFromExpression(initialExpression) - return { - pose, - geometry: renderAvatar( - poseWithAvatarEyes(initialExpression, initialAvatar.eyes), - surface, - 1, - { bodyNodes } - ), - } - }) - const { pose: initialPose, geometry: initialGeometry } = initialRender - const displayedPose = useRef(initialPose) - const transitionFrame = useRef(null) - const ambientFrame = useRef(null) - const ambientStartedAt = useRef(0) - const lastAmbientElapsed = useRef(0) - const lastAmbientFrame = useRef(0) - const ambientSignature = useRef('none:none') - const transitionTarget = useRef({ ...initialExpression }) - const canonicalTarget = useRef({ ...initialExpression }) - const retargetFrom = useRef(null) - const retargetTo = useRef(null) - const retargetStartedAt = useRef(null) - const [transitionVelocity] = useState( - () => - Object.fromEntries(expressionFields.map(field => [field, 0])) as Record< - (typeof expressionFields)[number], - number - > - ) - const lastTransitionTime = useRef(null) - const lastInspectorFrame = useRef(0) - const springSpeedRef = useRef(springSpeed) - const sequenceTransitionRef = useRef>({ - transitionMs: 500, - transition: 'smooth', - }) - const activeSequenceTransition = useRef<{ - target: Expression - index: number | null - settings: Pick - remainingMs: number - } | null>(null) - const pausedSequenceTransition = useRef(null) - const blinkControls = useRef | null>(null) - const blinkAnimating = useRef(false) - const blinkValue = useMotionValue(1) - const [renderedScene] = useState(() => createRenderedScene(initialGeometry)) - const [renderedRotationGizmo] = useState(() => createRenderedRotationGizmo(initialExpression)) - const bodyColorAnimation = useRef | null>(null) - const eyeColorAnimation = useRef | null>(null) - - const paintPose = (pose: AvatarPose, blink?: number, frameTimeMs?: number) => { - displayedPose.current = pose - paintRenderedRotationGizmo(renderedRotationGizmo, pose.expression) - const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) - const signature = `${pose.expression.eyeMotion}:${pose.expression.bodyMotion}` - if (signature !== ambientSignature.current) { - ambientSignature.current = signature - ambientStartedAt.current = -1 - lastAmbientElapsed.current = 0 - } - if (frameTimeMs !== undefined) { - if (ambientStartedAt.current < 0) ambientStartedAt.current = frameTimeMs - lastAmbientElapsed.current = frameTimeMs - ambientStartedAt.current - } - const renderedExpression = - !reduceMotion && hasAmbientMotion(pose.expression) - ? applyAmbientMotion(pose.expression, lastAmbientElapsed.current) - : pose.expression - const renderPose = avatar - ? poseWithAvatarEyes(renderedExpression, avatar.eyes ?? defaultAvatarEyes) - : poseFromExpression(renderedExpression) - const geometry = renderAvatar(renderPose, surfaceRef.current, blink ?? blinkValue.get(), { - includeWire: showWireRef.current || highlightRef.current === 'head', - bodyNodes: bodyNodesRef.current, - }) - paintRenderedScene(renderedScene, geometry) - paintRenderedOffset( - renderedScene, - ambientBodyOffset(pose.expression, lastAmbientElapsed.current) - ) - } - - useMotionValueEvent(blinkValue, 'change', latest => paintPose(displayedPose.current, latest)) - - const paintAmbientFrame = useEffectEvent((time: number) => { - if (transitionFrame.current === null && time - lastAmbientFrame.current >= AMBIENT_FRAME_MS) { - lastAmbientFrame.current = time - paintPose(displayedPose.current, undefined, time) - } - }) - - const ambientLoopActive = !reduceMotion && hasAmbientMotion(editing?.draft ?? expression) - useEffect(() => { - if (!ambientLoopActive) return - const tick = (time: number) => { - paintAmbientFrame(time) - ambientFrame.current = requestAnimationFrame(tick) - } - ambientFrame.current = requestAnimationFrame(tick) - return () => { - if (ambientFrame.current !== null) cancelAnimationFrame(ambientFrame.current) - } - }, [ambientLoopActive]) - - useEffect( - () => () => { - if (transitionFrame.current !== null) cancelAnimationFrame(transitionFrame.current) - blinkControls.current?.stop() - bodyColorAnimation.current?.stop() - eyeColorAnimation.current?.stop() - if (stateTimer.current) clearTimeout(stateTimer.current) - if (blinkTimer.current) clearTimeout(blinkTimer.current) - }, - [] - ) - - const stopTransition = (resetVelocity: boolean) => { - if (transitionFrame.current !== null) cancelAnimationFrame(transitionFrame.current) - transitionFrame.current = null - lastTransitionTime.current = null - retargetFrom.current = null - retargetTo.current = null - retargetStartedAt.current = null - lastInspectorFrame.current = 0 - if (resetVelocity) { - expressionFields.forEach(field => { - transitionVelocity[field] = 0 - }) - } - } - - const stopColorTransitions = () => { - bodyColorAnimation.current?.stop() - eyeColorAnimation.current?.stop() - bodyColorAnimation.current = null - eyeColorAnimation.current = null - } - - const freezeLivePreviewForManipulation = () => { - if (statePlaying) pauseState() - const renderedExpression = { ...displayedPose.current.expression } - stopTransition(true) - stopColorTransitions() - activeSequenceTransition.current = null - pausedSequenceTransition.current = null - transitionTarget.current = renderedExpression - canonicalTarget.current = renderedExpression - setExpression(renderedExpression) - setActiveExpression(null) - return renderedExpression - } - - const updateImmediate = (next: Expression, preservePlayback = false) => { - if (!preservePlayback && statePlaying) pauseState() - stopTransition(true) - stopColorTransitions() - const pose = poseFromExpression(next) - transitionTarget.current = next - canonicalTarget.current = next - setExpression(next) - const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) - if (avatar) setDisplayColors(resolveColors(next, avatar.colors)) - setActiveExpression(null) - paintPose(pose) - } - - const transitionToExpression = ( - next: Expression, - index: number | null = null, - transitionSettings?: Pick - ) => { - if (!transitionSettings && statePlaying) pauseState() - sequenceTransitionRef.current = transitionSettings ?? { - transitionMs: 500, - transition: 'smooth', - } - setActiveExpression(index) - const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) - if (reduceMotion || transitionSettings?.transitionMs === 0) { - updateImmediate(next, Boolean(transitionSettings)) - setActiveExpression(index) - return - } - const current = displayedPose.current.expression - const nearestAngle = (target: number, from: number) => { - let resolved = target - while (resolved - from > 180) resolved -= 360 - while (resolved - from < -180) resolved += 360 - return resolved - } - canonicalTarget.current = next - const resolvedTarget = { - ...next, - headX: nearestAngle(next.headX, current.headX), - headY: nearestAngle(next.headY, current.headY), - headZ: nearestAngle(next.headZ, current.headZ), - leftAngle: nearestAngle(next.leftAngle, current.leftAngle), - rightAngle: nearestAngle(next.rightAngle, current.rightAngle), - } - - if (transitionSettings) { - stopTransition(true) - stopColorTransitions() - const durationMs = transitionSettings.transitionMs - const from = { ...current } - const fromColors = { - body: renderedColors.body.get(), - eyes: renderedColors.eyes.get(), - } - const targetColors = avatar ? resolveColors(next, avatar.colors) : fromColors - let startedAt: number | null = null - activeSequenceTransition.current = { - target: next, - index, - settings: transitionSettings, - remainingMs: durationMs, - } - const tickSequenceTransition = (time: number) => { - if (startedAt === null) startedAt = time - const elapsed = time - startedAt - const progress = Math.min(elapsed / durationMs, 1) - const eased = - transitionSettings.transition === 'smooth' - ? progress * progress * (3 - 2 * progress) - : transitionSettings.transition === 'snappy' - ? 1 - (1 - progress) ** 3 - : 1 - Math.exp(-6 * progress) * Math.cos(8 * progress) - const animated = { ...from, eyeMotion: next.eyeMotion, bodyMotion: next.bodyMotion } - expressionFields.forEach(field => { - animated[field] = from[field] + (resolvedTarget[field] - from[field]) * eased - }) - activeSequenceTransition.current = { - target: next, - index, - settings: transitionSettings, - remainingMs: Math.max(durationMs - elapsed, 0), - } - setDisplayColors({ - body: interpolateHexColor(fromColors.body, targetColors.body, bounded(eased, 0, 1)), - eyes: interpolateHexColor(fromColors.eyes, targetColors.eyes, bounded(eased, 0, 1)), - }) - paintPose(poseFromExpression(animated), undefined, time) - if ( - modeRef.current === 'manual' && - time - lastInspectorFrame.current >= INSPECTOR_FRAME_MS - ) { - lastInspectorFrame.current = time - setExpression(animated) - } - if (progress < 1) { - transitionFrame.current = requestAnimationFrame(tickSequenceTransition) - return - } - transitionFrame.current = null - activeSequenceTransition.current = null - canonicalTarget.current = next - transitionTarget.current = next - setExpression(next) - setDisplayColors(targetColors) - paintPose(poseFromExpression(next)) - } - transitionFrame.current = requestAnimationFrame(tickSequenceTransition) - return - } - - if (avatar) { - const targetColors = resolveColors(next, avatar.colors) - bodyColorAnimation.current = animate(renderedColors.body, targetColors.body, { - duration: 0.35, - ease: 'easeInOut', - }) - eyeColorAnimation.current = animate(renderedColors.eyes, targetColors.eyes, { - duration: 0.35, - ease: 'easeInOut', - }) - } - - if (transitionFrame.current !== null) { - retargetFrom.current = { ...transitionTarget.current } - retargetTo.current = resolvedTarget - retargetStartedAt.current = -1 - return - } - transitionTarget.current = resolvedTarget - const tick = (time: number) => { - const previousTime = lastTransitionTime.current ?? time - const deltaTime = Math.min(Math.max((time - previousTime) / 1000, 1 / 240), 1 / 30) - lastTransitionTime.current = time - const { stiffness, damping } = getSequenceSpring( - sequenceTransitionRef.current.transition, - sequenceTransitionRef.current.transitionMs, - springSpeedRef.current - ) - const mass = 0.85 - const currentExpression = displayedPose.current.expression - if (retargetStartedAt.current !== null && retargetFrom.current && retargetTo.current) { - if (retargetStartedAt.current < 0) retargetStartedAt.current = time - const linearProgress = Math.min((time - retargetStartedAt.current) / RETARGET_BLEND_MS, 1) - const smoothProgress = - linearProgress ** 3 * (linearProgress * (linearProgress * 6 - 15) + 10) - const blendedTarget = { ...retargetTo.current } - expressionFields.forEach(field => { - blendedTarget[field] = - retargetFrom.current![field] + - (retargetTo.current![field] - retargetFrom.current![field]) * smoothProgress - }) - transitionTarget.current = blendedTarget - if (linearProgress === 1) { - transitionTarget.current = retargetTo.current - retargetFrom.current = null - retargetTo.current = null - retargetStartedAt.current = null - } - } - const target = transitionTarget.current - let settled = true - const animated = { ...currentExpression } - animated.eyeMotion = target.eyeMotion - animated.bodyMotion = target.bodyMotion - - expressionFields.forEach(field => { - const displacement = target[field] - currentExpression[field] - const acceleration = (stiffness * displacement - damping * transitionVelocity[field]) / mass - const velocity = transitionVelocity[field] + acceleration * deltaTime - const value = currentExpression[field] + velocity * deltaTime - transitionVelocity[field] = velocity - animated[field] = value - const tolerance = field === 'perspective' ? 0.0001 : 0.005 - if (Math.abs(displacement) > tolerance || Math.abs(velocity) > tolerance) settled = false - }) - - if (settled) { - const finalExpression = canonicalTarget.current - stopTransition(true) - setExpression(finalExpression) - paintPose(poseFromExpression(finalExpression)) - return - } - - paintPose(poseFromExpression(animated), undefined, time) - if (modeRef.current === 'manual' && time - lastInspectorFrame.current >= INSPECTOR_FRAME_MS) { - lastInspectorFrame.current = time - setExpression(animated) - } - transitionFrame.current = requestAnimationFrame(tick) - } - transitionFrame.current = requestAnimationFrame(tick) - } - - const blink = (durationMs?: number) => { - blinkControls.current?.stop() - blinkValue.jump(1) - blinkAnimating.current = true - const sequence = sequences.find(item => item.id === (activeState ?? selectedState)) - const blinkDuration = durationMs ?? sequence?.blink.durationMs ?? 280 - blinkControls.current = animate(blinkValue, [1, 0, 1], { - duration: reduceMotion ? 0 : blinkDuration / 1000, - times: [0, 0.42, 1], - ease: ['easeIn', 'easeOut'], - onComplete: () => { - blinkAnimating.current = false - }, - }) - } - - const updateDimension = (side: Side, dimension: 'width' | 'height', value: number) => { - updateImmediate(updateEyeDimension(expression, side, dimension, value, linked[dimension])) - } - - const updateSize = (side: Side, value: number) => { - updateImmediate(scaleEye(expression, side, value, linked.size)) - } - - const updateSpacing = (value: number) => { - updateImmediate({ ...expression, spacing: value }) - } - - const updateActiveAvatar = (update: (avatar: StudioAvatar) => StudioAvatar) => { - const next = avatarsRef.current.map(avatar => - avatar.id === activeAvatarIdRef.current ? update(avatar) : avatar - ) - avatarsRef.current = next - setAvatars(next) - if (!avatarEditSnapshot.current) { - updateStudioLibrary({ activeAvatarId: activeAvatarIdRef.current, avatars: next }) - } - } - - const selectBodyNode = (id: 'primary' | string | null) => { - setSelectedBodyNodeId(id) - if (id) setSelectedEyeSide(null) - } - - const updateSurface = (next: SurfaceConfig) => { - surfaceRef.current = next - setSurface(next) - updateActiveAvatar(avatar => ({ - ...avatar, - body: { primary: next, nodes: bodyNodesRef.current }, - })) - paintPose(displayedPose.current) - } - - const updateBodyNodes = (next: BodyNode[]) => { - bodyNodesRef.current = next - setBodyNodes(next) - updateActiveAvatar(avatar => ({ - ...avatar, - body: { primary: surfaceRef.current, nodes: next }, - })) - paintPose(displayedPose.current) - } - - const updateAvatarColors = (changes: Partial) => { - const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) - if (!avatar) return - const colors = { ...avatar.colors, ...changes } - updateActiveAvatar(current => ({ ...current, colors })) - setDisplayColors(resolveColors(expression, colors)) - } - - const updateAvatarEyes = (changes: Partial) => { - const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) - if (!avatar) return - const eyes = { ...(avatar.eyes ?? defaultAvatarEyes), ...changes } - updateActiveAvatar(current => ({ ...current, eyes })) - paintPose(displayedPose.current) - } - - const activateAvatar = (id: string, editBody = false, preserveMode = false) => { - const avatar = avatarsRef.current.find(item => item.id === id) - if (!avatar) return - const resumeActiveSequence = statePlaying - if (resumeActiveSequence) pauseState(false) - if (editBody) suspendStateForEditor() - if (editBody && !avatarEditSnapshot.current) { - avatarEditSnapshot.current = { - avatars: avatarsRef.current, - activeAvatarId: activeAvatarIdRef.current, - } - } - const currentStateExpression = displayedPose.current.expression - const nextBehavior = resolveAvatarBehavior(avatar, baseBehavior) - const nextExpressions = nextBehavior.expressions - const nextSequences = nextBehavior.sequences - stopTransition(true) - activeAvatarIdRef.current = id - surfaceRef.current = avatar.body.primary - bodyNodesRef.current = avatar.body.nodes - setActiveAvatarId(id) - setSurface(avatar.body.primary) - setBodyNodes(avatar.body.nodes) - expressionsRef.current = nextExpressions - sequencesRef.current = nextSequences - expressionDragPreview.current = nextExpressions - stateDragPreview.current = nextSequences - setExpressions(nextExpressions) - setSequences(nextSequences) - setExportAnimationIds(nextSequences.map(animation => animation.id)) - const nextActiveSequence = activeState - ? (nextSequences.find(sequence => sequence.id === activeState) ?? null) - : null - const nextSelectedState = - nextActiveSequence?.id ?? - (nextSequences.some(sequence => sequence.id === 'idle') - ? 'idle' - : (nextSequences[0]?.id ?? '')) - activeSequenceRef.current = nextActiveSequence - setActiveState(nextActiveSequence?.id ?? null) - setSelectedState(nextSelectedState) - setPlaybackVisual(current => ({ ...current, position: null })) - selectBodyNode('primary') - setActiveExpression(null) - setEditing(null) - setBodyEditing(editBody) - if (!preserveMode || editBody) setMode('manual') - const nextExpression = nextActiveSequence - ? currentStateExpression - : { ...(nextExpressions[0] ?? defaultExpression) } - setExpression(nextExpression) - setDisplayColors(resolveColors(nextExpression, avatar.colors)) - canonicalTarget.current = nextExpression - transitionTarget.current = nextExpression - paintPose(poseFromExpression(nextExpression)) - if (nextActiveSequence && resumeActiveSequence) { - pausedSequenceTransition.current = null - launchSequence(nextActiveSequence, true, false) - } - if (!avatarEditSnapshot.current) { - updateStudioLibrary({ activeAvatarId: id, avatars: avatarsRef.current }) - } - } - - const createNewAvatar = () => { - avatarEditSnapshot.current = { - avatars: avatarsRef.current, - activeAvatarId: activeAvatarIdRef.current, - } - const avatar = createAvatar('Unknown') - const next = [...avatarsRef.current, avatar] - avatarsRef.current = next - setAvatars(next) - setFocusAvatarName(true) - activateAvatar(avatar.id, true) - } - - const duplicateAvatar = (source: StudioAvatar, editDuplicate = false) => { - const snapshotAvatars = avatarEditSnapshot.current?.avatars ?? avatarsRef.current - const sourceIndex = snapshotAvatars.findIndex(avatar => avatar.id === source.id) - const baseAvatars = sourceIndex < 0 ? [...snapshotAvatars, source] : snapshotAvatars - const duplicate: StudioAvatar = { - ...structuredClone(source), - id: `avatar-${crypto.randomUUID()}`, - name: `${source.name} ${t('copie')}`, - } - const next = [...baseAvatars, duplicate] - avatarEditSnapshot.current = null - avatarsRef.current = next - setAvatars(next) - updateStudioLibrary({ activeAvatarId: duplicate.id, avatars: next }) - activateAvatar(duplicate.id, editDuplicate) - } - - const previewAvatarMove = (targetId: string) => { - const draggedId = draggedAvatarId.current - if (!draggedId || draggedId === targetId) return - const current = avatarDragPreview.current - const dragged = current.find(avatar => avatar.id === draggedId) - const targetIndex = current.findIndex(avatar => avatar.id === targetId) - if (!dragged || targetIndex < 0) return - const next = current.filter(avatar => avatar.id !== draggedId) - next.splice(targetIndex, 0, dragged) - avatarDragPreview.current = next - setAvatars(next) - } - - const commitAvatarMove = (targetId: string) => { - previewAvatarMove(targetId) - const next = avatarDragPreview.current - avatarsRef.current = next - updateStudioLibrary({ activeAvatarId: activeAvatarIdRef.current, avatars: next }) - avatarDragOrigin.current = null - draggedAvatarId.current = null - setDraggingAvatarId(null) - } - - const cancelAvatarMove = () => { - if (draggedAvatarId.current && avatarDragOrigin.current) { - avatarDragPreview.current = avatarDragOrigin.current - setAvatars(avatarDragOrigin.current) - } - avatarDragOrigin.current = null - draggedAvatarId.current = null - setDraggingAvatarId(null) - } - - const cancelAvatarEditing = () => { - const snapshot = avatarEditSnapshot.current - if (!snapshot) { - setBodyEditing(false) - restoreStateAfterEditor() - return - } - avatarEditSnapshot.current = null - avatarsRef.current = snapshot.avatars - setAvatars(snapshot.avatars) - activateAvatar(snapshot.activeAvatarId, false, true) - restoreStateAfterEditor() - } - - const saveAvatarEditing = () => { - avatarEditSnapshot.current = null - updateStudioLibrary({ activeAvatarId: activeAvatarIdRef.current, avatars: avatarsRef.current }) - setBodyEditing(false) - restoreStateAfterEditor() - } - - const renameActiveAvatar = (name: string) => { - updateActiveAvatar(avatar => ({ ...avatar, name })) - } - - const deleteActiveAvatar = () => { - if (avatarsRef.current.length <= 1) return - avatarEditSnapshot.current = null - const remaining = avatarsRef.current.filter(avatar => avatar.id !== activeAvatarIdRef.current) - avatarsRef.current = remaining - setAvatars(remaining) - updateStudioLibrary({ activeAvatarId: remaining[0].id, avatars: remaining }) - setDeleteAvatarOpen(false) - activateAvatar(remaining[0].id) - restoreStateAfterEditor() - } - - const addBodyNode = (type: (typeof bodyPrimitiveTypes)[number]) => { - if (bodyNodesRef.current.length >= MAX_BODY_NODES) return - const node = createBodyNode(type, bodyNodesRef.current.length) - updateBodyNodes([...bodyNodesRef.current, node]) - selectBodyNode(node.id) - } - - const updateSelectedBodyNode = (update: (node: BodyNode) => BodyNode) => { - if (selectedBodyNodeId === 'primary') return - updateBodyNodes( - bodyNodesRef.current.map(node => (node.id === selectedBodyNodeId ? update(node) : node)) - ) - } - - const commitBodyNode = (nextNode: BodyNode) => { - updateBodyNodes(bodyNodesRef.current.map(node => (node.id === nextNode.id ? nextNode : node))) - } - - const previewSelectedBodyNode = (nextNode: BodyNode) => { - const next = bodyNodesRef.current.map(node => (node.id === nextNode.id ? nextNode : node)) - bodyNodesRef.current = next - setBodyNodes(next) - paintPose(displayedPose.current) - } - - const deleteSelectedBodyNode = () => { - if (selectedBodyNodeId === 'primary') return - updateBodyNodes(bodyNodesRef.current.filter(node => node.id !== selectedBodyNodeId)) - selectBodyNode('primary') - } - - const duplicateSelectedBodyNode = () => { - if (selectedBodyNodeId === 'primary' || bodyNodesRef.current.length >= MAX_BODY_NODES) return - const source = bodyNodesRef.current.find(node => node.id === selectedBodyNodeId) - if (!source) return - const duplicate = duplicateBodyNode(source) - updateBodyNodes([...bodyNodesRef.current, duplicate]) - selectBodyNode(duplicate.id) - } - - const updateHighlight = (next: Highlight) => { - if (next && statePlaying) pauseState() - highlightRef.current = next - setHighlight(next) - if (next === 'head') paintPose(displayedPose.current) - } - - const updateWireVisibility = (next: boolean) => { - showWireRef.current = next - setShowWire(next) - if (next) paintPose(displayedPose.current) - } - - const clearStateTimers = () => { - if (stateTimer.current) clearTimeout(stateTimer.current) - if (blinkTimer.current) clearTimeout(blinkTimer.current) - stateTimer.current = null - blinkTimer.current = null - playbackTimeline.current = { - ...playbackTimeline.current, - stepDueAt: null, - blinkDueAt: null, - } - } - - const pauseState = (persist = true) => { - playbackTimeline.current = pausePlaybackTimeline(playbackTimeline.current, readSequenceClock()) - if (transitionFrame.current !== null && activeSequenceTransition.current) { - cancelAnimationFrame(transitionFrame.current) - transitionFrame.current = null - pausedSequenceTransition.current = activeSequenceTransition.current - activeSequenceTransition.current = null - } - if (blinkAnimating.current) blinkControls.current?.pause() - clearStateTimers() - setStatePlaying(false) - setPlaybackStatus('paused') - if (persist && activeState) persistStatePlayback({ stateId: activeState, playing: false }) - } - - const stopState = (persist = true) => { - clearStateTimers() - playbackTimeline.current = stopPlaybackTimeline(playbackTimeline.current) - activeSequenceTransition.current = null - pausedSequenceTransition.current = null - stopTransition(true) - blinkControls.current?.stop() - blinkAnimating.current = false - blinkValue.jump(1) - paintPose(displayedPose.current, 1) - setStatePlaying(false) - setPlaybackStatus('stopped') - setPlaybackVisual(current => ({ ...current, position: null })) - if (persist) persistStatePlayback({ stateId: null, playing: false }) - } - - const launchSequence = (sequence: AvatarSequence, resume = false, persist = true) => { - clearStateTimers() - if (!sequence.steps.length) { - stopState(persist) - return - } - const id = sequence.id - playbackTimeline.current = beginPlayback(playbackTimeline.current, resume) - setSelectedState(id) - setActiveState(id) - activeSequenceRef.current = sequence - setStatePlaying(true) - setPlaybackStatus('playing') - if (persist) persistStatePlayback({ stateId: id, playing: true }) - const scheduleAdvance = (delay: number) => { - playbackTimeline.current = schedulePlaybackStep( - playbackTimeline.current, - readSequenceClock(), - delay - ) - stateTimer.current = setTimeout(advance, delay) - } - const playCurrentStep = () => { - playbackTimeline.current = { ...playbackTimeline.current, stepDueAt: null } - const step = sequence.steps[playbackTimeline.current.position] - const availableExpressions = expressionsRef.current - const expressionIndex = findExpressionIndex(availableExpressions, step.expressionId) - const preset = availableExpressions[expressionIndex] - const durationMs = (reduceMotion ? 0 : step.transitionMs) + step.holdMs - setPlaybackVisual(current => ({ - position: playbackTimeline.current.position, - run: current.run + 1, - durationMs, - })) - if (preset) { - transitionToExpression(preset, expressionIndex, step) - } - scheduleAdvance(durationMs) - } - const advance = () => { - const advanced = advancePlaybackTimeline(playbackTimeline.current, sequence) - playbackTimeline.current = advanced.timeline - const { cursor } = advanced - if (cursor.complete) { - if (blinkTimer.current) clearTimeout(blinkTimer.current) - blinkTimer.current = null - playbackTimeline.current = { ...playbackTimeline.current, blinkDueAt: null } - setStatePlaying(false) - setPlaybackStatus('stopped') - setPlaybackVisual(current => ({ ...current, position: null })) - if (persist) persistStatePlayback({ stateId: null, playing: false }) - return - } - playCurrentStep() - } - const scheduleBlink = (delay: number) => { - playbackTimeline.current = schedulePlaybackBlink( - playbackTimeline.current, - readSequenceClock(), - delay - ) - blinkTimer.current = setTimeout(blinkLoop, delay) - } - const blinkLoop = () => { - playbackTimeline.current = { ...playbackTimeline.current, blinkDueAt: null } - blink(sequence.blink.durationMs) - const { minIntervalMs, maxIntervalMs } = sequence.blink - scheduleBlink( - sequence.blink.durationMs + minIntervalMs + Math.random() * (maxIntervalMs - minIntervalMs) - ) - } - if (resume) { - const pausedTransition = pausedSequenceTransition.current - if (pausedTransition) { - transitionToExpression(pausedTransition.target, pausedTransition.index, { - ...pausedTransition.settings, - transitionMs: pausedTransition.remainingMs, - }) - pausedSequenceTransition.current = null - } - if (blinkAnimating.current) blinkControls.current?.play() - const currentStep = sequence.steps[playbackTimeline.current.position] - scheduleAdvance( - playbackTimeline.current.stepRemainingMs || - (reduceMotion ? 0 : currentStep.transitionMs) + currentStep.holdMs - ) - } else { - playCurrentStep() - } - if (sequence.blink.enabled) { - scheduleBlink( - resume - ? playbackTimeline.current.blinkRemainingMs || sequence.blink.initialDelayMs - : sequence.blink.initialDelayMs - ) - } - } - - const toggleStatePlayback = () => { - if (!activeState || !activeSequenceRef.current) return - if (statePlaying) pauseState() - else launchSequence(activeSequenceRef.current, playbackStatus === 'paused') - } - - const suspendStateForEditor = () => { - if (editorStateSnapshot.current || !activeState) return - editorStateSnapshot.current = { - stateId: activeState, - playing: statePlaying, - expression: { ...displayedPose.current.expression }, - } - if (statePlaying) pauseState(false) - setActiveState(null) - } - - const restoreStateAfterEditor = (availableSequences = sequences) => { - const snapshot = editorStateSnapshot.current - editorStateSnapshot.current = null - if (!snapshot) return - const sequence = availableSequences.find(item => item.id === snapshot.stateId) - if (!sequence) { - stopState(false) - persistStatePlayback({ stateId: null, playing: false }) - return - } - activeSequenceRef.current = sequence - setSelectedState(sequence.id) - setActiveState(sequence.id) - if (snapshot.playing) { - launchSequence(sequence, true, false) - return - } - setStatePlaying(false) - transitionToExpression(snapshot.expression) - } - - useEffect(() => { - if (initialStatePlaybackApplied.current) return - initialStatePlaybackApplied.current = true - const sequence = sequences.find(item => item.id === selectedState) - if (sequence && initialStatePlayback.stateId !== null) { - activeSequenceRef.current = sequence - setActiveState(sequence.id) - if (initialStatePlayback.playing) launchSequence(sequence, false, false) - else setPlaybackStatus('paused') - } - return () => { - initialStatePlaybackApplied.current = false - } - }, []) - - const saveEditing = () => { - if (!editing) return - const index = editing.index ?? expressions.length - const savedDraft = - editing.index === null ? { ...editing.draft, id: createExpressionId() } : editing.draft - const next = - editing.index === null - ? [...expressions, savedDraft] - : expressions.map((item, itemIndex) => - itemIndex === editing.index ? { ...savedDraft } : item - ) - setExpressions(next) - updateStudioExpressions(next) - setEditing(null) - transitionToExpression(savedDraft, index) - if (!sequenceEditing) restoreStateAfterEditor() - } - - const duplicateExpression = (_index: number | null, draft: Expression, editDuplicate = false) => { - const duplicate = { ...draft, id: createExpressionId() } - const next = [...expressions, duplicate] - const duplicateIndex = next.length - 1 - setExpressions(next) - updateStudioExpressions(next) - if (editDuplicate) openExpressionEditor(duplicateIndex, duplicate) - else transitionToExpression(duplicate, duplicateIndex) - } - - const previewExpressionMove = (targetId: string | null) => { - const draggedId = draggedExpressionId.current - if (!draggedId || draggedId === targetId) return - const current = expressionDragPreview.current - const dragged = current.find(item => item.id === draggedId) - if (!dragged) return - const activeId = activeExpression === null ? null : current[activeExpression]?.id - const next = current.filter(item => item.id !== draggedId) - const targetIndex = targetId ? next.findIndex(item => item.id === targetId) : next.length - next.splice(targetIndex < 0 ? next.length : targetIndex, 0, dragged) - expressionDragPreview.current = next - setExpressions(next) - if (activeId) setActiveExpression(next.findIndex(item => item.id === activeId)) - } - - const commitExpressionMove = (targetId: string | null) => { - previewExpressionMove(targetId) - updateStudioExpressions(expressionDragPreview.current) - expressionDragOrigin.current = null - draggedExpressionId.current = null - setDraggingExpressionId(null) - } - - const cancelExpressionMove = () => { - if (draggedExpressionId.current && expressionDragOrigin.current) { - expressionDragPreview.current = expressionDragOrigin.current - setExpressions(expressionDragOrigin.current) - } - expressionDragOrigin.current = null - draggedExpressionId.current = null - setDraggingExpressionId(null) - } - - const previewExpressionDraft = (draft: Expression) => { - setEditing(current => (current ? { ...current, draft } : current)) - const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) - if (avatar) setDisplayColors(resolveColors(draft, avatar.colors)) - paintPose(poseFromExpression(draft)) - } - - const previewCanvasExpression = (next: Expression, target: CanvasPreviewTarget) => { - const now = performance.now() - const updateInspector = now - lastInspectorFrame.current >= INSPECTOR_FRAME_MS - if (updateInspector) { - lastInspectorFrame.current = now - if (editing) { - setEditing(current => (current ? { ...current, draft: next } : current)) - } else { - setExpression(next) - } - } - if (bodyEditing) { - paintRenderedRotationGizmo(renderedRotationGizmo, next) - paintRenderedScene( - renderedScene, - renderAvatar( - poseFromExpression( - resolveCanvasPreviewExpression(next, activeAvatarEyes, bodyEditing, target) - ), - surfaceRef.current, - blinkValue.get(), - { - includeWire: showWireRef.current || highlightRef.current === 'head', - bodyNodes: bodyNodesRef.current, - } - ) - ) - return - } - const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) - if (avatar) setDisplayColors(resolveColors(next, avatar.colors)) - paintPose(poseFromExpression(next)) - } - - const openExpressionEditor = (index: number | null, draft: Expression) => { - suspendStateForEditor() - setBodyEditing(false) - setMode('expressions') - setEditing({ - index, - draft: { ...draft, id: index === null ? createExpressionId() : draft.id }, - }) - const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) - if (avatar) setDisplayColors(resolveColors(draft, avatar.colors)) - paintPose(poseFromExpression(draft)) - } - - const cancelExpressionEditing = () => { - setEditing(null) - const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) - if (avatar) setDisplayColors(resolveColors(expression, avatar.colors)) - paintPose(poseFromExpression(expression)) - if (!sequenceEditing) restoreStateAfterEditor() - } - - const deleteEditing = () => { - if (editing?.index === null || editing?.index === undefined) return - const next = - expressions.length <= 1 - ? [{ ...defaultExpression }] - : expressions.filter((_, index) => index !== editing.index) - const fallback = next[Math.min(editing.index, next.length - 1)] ?? defaultExpression - const deletedExpressionId = expressions[editing.index]?.id ?? editing.draft.id - const fallbackExpressionId = fallback.id - const nextSequences = remapSequencesAfterExpressionDelete( - sequences, - deletedExpressionId, - fallbackExpressionId - ) - setExpressions(next) - setSequences(nextSequences) - if (sequenceEditing) { - const draft = remapSequencesAfterExpressionDelete( - [sequenceEditing.draft], - deletedExpressionId, - fallbackExpressionId - )[0] - setSequenceEditing({ ...sequenceEditing, draft }) - } - updateStudioExpressions(next) - updateStudioSequences(nextSequences) - setActiveExpression(null) - setEditing(null) - setDeleteExpressionOpen(false) - setExpression(fallback) - const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) - if (avatar) setDisplayColors(resolveColors(fallback, avatar.colors)) - paintPose(poseFromExpression(fallback)) - if (!sequenceEditing) restoreStateAfterEditor() - } - - const openSequenceEditor = (sequence?: AvatarSequence) => { - suspendStateForEditor() - setEditing(null) - setBodyEditing(false) - setMode('states') - const draft = sequence - ? { - ...sequence, - steps: sequence.steps.map(step => ({ ...step })), - blink: { ...sequence.blink }, - } - : createSequence(expressions[activeExpression ?? 0]?.id ?? expressions[0]?.id) - setSequenceEditing({ sourceId: sequence?.id ?? null, draft }) - setSelectedSequenceStepId(draft.steps[0]?.id ?? null) - const firstStep = draft.steps[0] - const expressionIndex = firstStep - ? findExpressionIndex(expressions, firstStep.expressionId) - : -1 - const preset = expressions[expressionIndex] - if (preset) transitionToExpression(preset, expressionIndex, firstStep) - } - - const cancelSequenceEditing = () => { - if (activeState === sequenceEditing?.draft.id) stopState(false) - setSequenceEditing(null) - setSelectedSequenceStepId(null) - restoreStateAfterEditor() - } - - const saveSequenceEditing = () => { - if (!sequenceEditing?.draft.steps.length || !sequenceEditing.draft.name.trim()) return - const saved = { - ...sequenceEditing.draft, - name: sequenceEditing.draft.name.trim(), - group: sequenceEditing.draft.group.trim() || 'Custom', - } - const next = sequenceEditing.sourceId - ? sequences.map(sequence => (sequence.id === sequenceEditing.sourceId ? saved : sequence)) - : [...sequences, saved] - setSequences(next) - updateStudioSequences(next) - setSelectedState(saved.id) - if (activeState === saved.id) stopState(false) - setSequenceEditing(null) - setSelectedSequenceStepId(null) - restoreStateAfterEditor(next) - } - - const duplicateSequenceEditing = () => { - if (!sequenceEditing) return - if (activeState === sequenceEditing.draft.id) stopState(false) - const duplicate = duplicateSequence(sequenceEditing.draft) - const next = [...sequences, duplicate] - setSequences(next) - updateStudioSequences(next) - setSelectedState(duplicate.id) - setSequenceEditing({ sourceId: duplicate.id, draft: duplicate }) - setSelectedSequenceStepId(duplicate.steps[0]?.id ?? null) - } - - const duplicateState = (sequence: AvatarSequence) => { - if (activeState === sequence.id) stopState(false) - const duplicate = duplicateSequence(sequence) - const next = [...sequences, duplicate] - setSequences(next) - updateStudioSequences(next) - setSelectedState(duplicate.id) - } - - const previewStateMove = (targetId: string | null, targetGroup: string) => { - const draggedId = draggedStateId.current - if (!draggedId || draggedId === targetId) return - const current = stateDragPreview.current - const dragged = current.find(sequence => sequence.id === draggedId) - if (!dragged) return - const next = current.filter(sequence => sequence.id !== draggedId) - const moved = { ...dragged, group: targetGroup } - if (targetId) { - const targetIndex = next.findIndex(sequence => sequence.id === targetId) - next.splice(targetIndex < 0 ? next.length : targetIndex, 0, moved) - } else { - let lastGroupIndex = -1 - for (let index = next.length - 1; index >= 0; index -= 1) { - if (next[index].group !== targetGroup) continue - lastGroupIndex = index - break - } - next.splice(lastGroupIndex + 1, 0, moved) - } - stateDragPreview.current = next - setSequences(next) - } - - const commitStateMove = (targetId: string | null, targetGroup: string) => { - previewStateMove(targetId, targetGroup) - updateStudioSequences(stateDragPreview.current) - stateDragOrigin.current = null - draggedStateId.current = null - setDraggingStateId(null) - } - - const cancelStateMove = () => { - if (draggedStateId.current && stateDragOrigin.current) { - stateDragPreview.current = stateDragOrigin.current - setSequences(stateDragOrigin.current) - } - stateDragOrigin.current = null - draggedStateId.current = null - setDraggingStateId(null) - } - - const deleteSequenceEditing = () => { - if (!sequenceEditing?.sourceId) return - const next = sequences.filter(sequence => sequence.id !== sequenceEditing.sourceId) - const fallback = next[0] - setSequences(next) - updateStudioSequences(next) - if (activeState === sequenceEditing.sourceId) stopState(false) - setSelectedState(fallback?.id ?? '') - setSequenceEditing(null) - setSelectedSequenceStepId(null) - setDeleteSequenceOpen(false) - restoreStateAfterEditor(next) - } - - const selectedBodyNode = - selectedBodyNodeId === 'primary' - ? null - : (bodyNodes.find(node => node.id === selectedBodyNodeId) ?? null) - - const updateNodeVector = (property: 'position' | 'rotation', index: 0 | 1 | 2, value: number) => { - updateSelectedBodyNode(node => { - const vector = [...node[property]] as [number, number, number] - vector[index] = value - return { ...node, [property]: vector } - }) - } - const activeAvatar = avatars.find(avatar => avatar.id === activeAvatarId) ?? avatars[0] - const activeAvatarEyes = activeAvatar.eyes ?? defaultAvatarEyes - const activeSequence = sequences.find(sequence => sequence.id === activeState) ?? null - const activeSequenceLabel = activeSequence - ? activeSequence.builtIn - ? t(activeSequence.name) - : activeSequence.name - : null - const expressionById = new Map(expressions.map(item => [item.id, item])) - const exportAnimationIdSet = new Set(exportAnimationIds) - const selectedExportAnimations = sequences.filter(animation => - exportAnimationIdSet.has(animation.id) - ) - const toggleExportAnimation = (animationId: string) => { - setExportAnimationIds(current => - current.includes(animationId) - ? current.filter(id => id !== animationId) - : [...current, animationId] - ) - } - const downloadAvatarExport = () => { - if (!selectedExportAnimations.length) return - const payload = createAvatarExportPayload(activeAvatar, expressions, selectedExportAnimations) - const isReact = exportFormat === 'react' - const extension = 'zip' - const blob = isReact - ? generateReactAvatarPackage(payload) - : generateJavaScriptAvatarPackage(payload, language) - downloadBlob(blob, avatarExportFileName(activeAvatar.name, extension)) - } - const currentStudioDocument = (): StudioDocument => ({ - version: 2, - library: { activeAvatarId, avatars }, - expressions: baseBehavior.expressions, - sequences: baseBehavior.sequences, - playback: { - stateId: playbackStatus === 'stopped' ? null : (activeState ?? (selectedState || null)), - playing: statePlaying, - }, - }) - const downloadStudioProject = () => { - const blob = new Blob([serializeStudioDocument(currentStudioDocument())], { - type: 'application/json', - }) - downloadBlob(blob, 'avatar-studio-project.json') - } - const currentSnapshotSvg = () => - serializeAvatarSnapshot( - activeAvatar.name, - renderedScene, - { - body: renderedColors.body.get(), - eyes: renderedColors.eyes.get(), - }, - { - background: snapshotBackground, - colorFrom: snapshotColorFrom, - colorTo: snapshotColorTo, - size: Number(snapshotSize), - } - ) - - const downloadSnapshotSvg = () => { - downloadBlob( - new Blob([currentSnapshotSvg()], { type: 'image/svg+xml;charset=utf-8' }), - snapshotFileName(activeAvatar.name) - ) - } - const downloadSnapshotPng = () => { - const size = Number(snapshotSize) - const source = new Blob([currentSnapshotSvg()], { type: 'image/svg+xml;charset=utf-8' }) - const sourceUrl = URL.createObjectURL(source) - const image = new Image() - image.onload = () => { - const canvas = document.createElement('canvas') - canvas.width = size - canvas.height = size - canvas.getContext('2d')?.drawImage(image, 0, 0, size, size) - URL.revokeObjectURL(sourceUrl) - canvas.toBlob(blob => { - if (blob) downloadBlob(blob, snapshotFileName(activeAvatar.name, 'png')) - }, 'image/png') - } - image.onerror = () => URL.revokeObjectURL(sourceUrl) - image.src = sourceUrl - } - const takePicture = () => { - setPhotoFlash(current => current + 1) - requestAnimationFrame(() => { - if (snapshotFormat === 'png') downloadSnapshotPng() - else downloadSnapshotSvg() - }) - } - const prepareStudioProjectImport = (file: File | undefined) => { - if (!file) return - setProjectImportError(null) - if (file.size > 10_000_000) { - setProjectImportError( - t('Ce fichier ne contient pas un projet Avatar Studio valide et compatible.') - ) - return - } - file - .text() - .then(source => { - const imported = parseImportedStudioDocument(source, currentStudioDocument()) - setPendingProjectImport({ document: imported, fileName: file.name }) - }) - .catch(() => { - setProjectImportError( - t('Ce fichier ne contient pas un projet Avatar Studio valide et compatible.') - ) - }) - } - const confirmStudioProjectImport = () => { - if (!pendingProjectImport) return - if (!persistStudioDocument(pendingProjectImport.document)) { - setProjectImportError( - t( - 'Le projet n’a pas pu être enregistré dans ce navigateur. Libère de l’espace puis réessaie.' - ) - ) - setPendingProjectImport(null) - return - } - window.location.reload() - } - const canvasExpression = editing?.draft ?? expression - const editorPageOpen = bodyEditing || editing !== null || sequenceEditing !== null - const expressionPendingDeletionId = - editing?.index !== null && editing?.index !== undefined - ? (expressions[editing.index]?.id ?? editing.draft.id) - : null - const animationsAffectedByExpressionDeletion = expressionPendingDeletionId - ? sequences.filter(sequence => - sequence.steps.some(step => step.expressionId === expressionPendingDeletionId) - ) - : [] - - useEffect(() => { - if (!editorPageOpen || focusAvatarName) return - const frame = requestAnimationFrame(() => workspaceBackButtonRef.current?.focus()) - return () => cancelAnimationFrame(frame) - }, [editorPageOpen, focusAvatarName]) - - const updateAvatarEyeDimension = (side: Side, dimension: 'width' | 'height', value: number) => { - const next = updateEyeDimension( - { ...defaultExpression, ...activeAvatarEyes }, - side, - dimension, - value, - linked[dimension] - ) - updateAvatarEyes({ - [`${dimension}Left`]: next[`${dimension}Left`], - [`${dimension}Right`]: next[`${dimension}Right`], - }) - } - const updateAvatarEyeSize = (side: Side, value: number) => { - const next = scaleEye({ ...defaultExpression, ...activeAvatarEyes }, side, value, linked.size) - updateAvatarEyes({ - widthLeft: next.widthLeft, - widthRight: next.widthRight, - heightLeft: next.heightLeft, - heightRight: next.heightRight, - }) - } - const updateAvatarEyePosition = (side: Side, axis: 'X' | 'Y', value: number) => { - const next = updateEyePosition( - { ...defaultExpression, ...activeAvatarEyes }, - side, - axis, - value, - linked.position - ) - updateAvatarEyes({ - [`position${axis}Left`]: next[`position${axis}Left`], - [`position${axis}Right`]: next[`position${axis}Right`], - }) - } - const persistEditedEyeExpression = (next: Expression) => { - updateAvatarEyes({ - widthLeft: next.widthLeft, - widthRight: next.widthRight, - heightLeft: next.heightLeft, - heightRight: next.heightRight, - spacing: next.spacing, - positionXLeft: next.positionXLeft, - positionXRight: next.positionXRight, - positionYLeft: next.positionYLeft, - positionYRight: next.positionYRight, - leftAngle: next.leftAngle, - rightAngle: next.rightAngle, - }) - } - - return ( -
- -
- - Bible Strong Avatar Lab -
-
- - -
- { - 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 && ( -
- -
- {sequenceEditing && !editing && ( - - - setSequenceEditing(current => (current ? { ...current, draft } : current)) - } - onPreviewStep={step => { - const expressionIndex = findExpressionIndex(expressions, step.expressionId) - const preset = expressions[expressionIndex] - if (preset) transitionToExpression(preset, expressionIndex, step) - }} - onEditExpression={openExpressionEditor} - onPlay={() => launchSequence(sequenceEditing.draft, false, false)} - onPause={() => pauseState(false)} - onStop={() => stopState(false)} - playing={statePlaying} - active={activeState === sequenceEditing.draft.id} - onCancel={cancelSequenceEditing} - onSave={saveSequenceEditing} - onDuplicate={duplicateSequenceEditing} - onDelete={() => setDeleteSequenceOpen(true)} - /> - - )} - {editing && ( - - duplicateExpression(editing.index, editing.draft, true)} - onDelete={() => setDeleteExpressionOpen(true)} - /> - - )} - {!sequenceEditing && - !editing && - (bodyEditing ? ( -
- -
-

{t('Construction du corps')}

- renameActiveAvatar(event.currentTarget.value)} - onFocus={event => { - if (focusAvatarName) event.currentTarget.select() - }} - onBlur={event => { - if (!event.currentTarget.value.trim()) renameActiveAvatar('Unknown') - setFocusAvatarName(false) - }} - /> -

- {t('Choisis la forme principale puis assemble les primitives autour d’elle.')} -

-
-
- ) : ( - <> -
-
- Avatars - {t('Double-clic pour modifier')} -
-
- {avatars.map(avatar => ( - - - { - avatarDragOrigin.current = avatarsRef.current - avatarDragPreview.current = avatarsRef.current - draggedAvatarId.current = avatar.id - setDraggingAvatarId(avatar.id) - event.dataTransfer.effectAllowed = 'move' - }} - onDragEnter={() => previewAvatarMove(avatar.id)} - onDragOver={event => { - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - }} - onDrop={event => { - event.preventDefault() - commitAvatarMove(avatar.id) - }} - onDragEnd={cancelAvatarMove} - onClick={() => activateAvatar(avatar.id, false, true)} - onDoubleClick={() => { - setFocusAvatarName(false) - activateAvatar(avatar.id, true) - }} - > - - {avatar.name} - - } - /> - - { - setFocusAvatarName(false) - activateAvatar(avatar.id, true) - }} - > - {t('Modifier')} - - duplicateAvatar(avatar)}> - {t('Dupliquer')} - - - { - activateAvatar(avatar.id, false, true) - setDeleteAvatarOpen(true) - }} - > - {t('Supprimer')} - - - - - ))} - -
-
- setMode(value as Mode)}> - - {t('Pose')} - {t('Expressions')} - {t('Animations')} - {t('Exporter')} - - - - ))} - - {!editing && mode === 'manual' && ( -
- {bodyEditing && ( - <> - - - -
- - {bodyNodes.map(node => ( - - ))} -
-
- - {t('Ajouter une forme')} · {bodyNodes.length}/{MAX_BODY_NODES} - -
- {bodyPrimitiveTypes.map(type => ( - - ))} -
-
- {selectedBodyNode && ( -
-
- {selectedBodyNode.name} -
- - -
-
-

- {t('Gizmo local')} - {t( - 'Glisse un axe pour déplacer la forme, ou un anneau pour la faire tourner.' - )} -

-
- - updateSelectedBodyNode(node => ({ - ...node, - surface: scaleSurface(node.surface, size, { - width: 10, - height: 10, - depth: 10, - }), - })) - } - /> - {(['width', 'height', 'depth'] as const).map(dimension => ( - - updateSelectedBodyNode(node => ({ - ...node, - surface: { ...node.surface, [dimension]: value }, - })) - } - /> - ))} - {(selectedBodyNode.surface.type === 'cube' || - selectedBodyNode.surface.type === 'diamond' || - selectedBodyNode.surface.type === 'cylinder') && ( - - updateSelectedBodyNode(node => ({ - ...node, - surface: { ...node.surface, roundness }, - })) - } - /> - )} - {(selectedBodyNode.surface.type === 'cylinder' || - selectedBodyNode.surface.type === 'cone') && ( - - updateSelectedBodyNode(node => ({ - ...node, - surface: { ...node.surface, morphRoundness }, - })) - } - /> - )} - {selectedBodyNode.surface.type === 'cone' && ( - <> - - updateSelectedBodyNode(node => ({ - ...node, - surface: { ...node.surface, tipRoundness }, - })) - } - /> - - updateSelectedBodyNode(node => ({ - ...node, - surface: { ...node.surface, baseRoundness }, - })) - } - /> - - )} -
-
-
-

{t('Position locale')}

- {(['X', 'Y', 'Z'] as const).map((axis, index) => ( - - updateNodeVector('position', index as 0 | 1 | 2, value) - } - /> - ))} -
-
-

{t('Rotation locale')}

- {(['X', 'Y', 'Z'] as const).map((axis, index) => ( - - updateNodeVector('rotation', index as 0 | 1 | 2, value) - } - /> - ))} -
-
-
- )} -
- - -
- {bodyPrimitiveTypes.map(type => { - const previewSurface = - type === surface.type ? surface : surfacePresets[type] - return ( - - ) - })} -
-
- - updateSurface( - scaleSurface(surface, size, { width: 120, height: 120, depth: 100 }) - ) - } - /> - updateSurface({ ...surface, width })} - /> - updateSurface({ ...surface, height })} - /> - updateSurface({ ...surface, depth })} - /> - {(surface.type === 'cube' || surface.type === 'diamond') && ( - updateHighlight(active ? 'head' : null)} - onChange={roundness => updateSurface({ ...surface, roundness })} - /> - )} - {surface.type === 'cylinder' && ( - updateHighlight(active ? 'head' : null)} - onChange={roundness => updateSurface({ ...surface, roundness })} - /> - )} - {(surface.type === 'cylinder' || surface.type === 'cone') && ( - updateHighlight(active ? 'head' : null)} - onChange={morphRoundness => updateSurface({ ...surface, morphRoundness })} - /> - )} - {surface.type === 'cone' && ( - <> - updateHighlight(active ? 'head' : null)} - onChange={tipRoundness => updateSurface({ ...surface, tipRoundness })} - /> - updateHighlight(active ? 'head' : null)} - onChange={baseRoundness => updateSurface({ ...surface, baseRoundness })} - /> - - )} -
-
- - - updateAvatarColors({ body })} - /> - -
- -

- {t( - 'Définis l’identité du regard de cet avatar. Les poses s’ajoutent ensuite à cette base.' - )} -

- {(['width', 'height', 'size'] as const).map(dimension => ( - -
-

- {t( - { - width: 'Largeur', - height: 'Hauteur', - size: 'Taille proportionnelle', - }[dimension] - )} -

- - setLinked(current => ({ - ...current, - [dimension]: !current[dimension], - })) - } - /> -
-
- {(['Left', 'Right'] as Side[]).map(side => { - const width = activeAvatarEyes[`width${side}`] - const height = activeAvatarEyes[`height${side}`] - const value = - dimension === 'width' - ? width - : dimension === 'height' - ? height - : Math.max(width, height) - return ( - - updateHighlight( - active - ? linked[dimension] - ? 'both' - : side === 'Left' - ? 'left' - : 'right' - : null - ) - } - onChange={next => - dimension === 'size' - ? updateAvatarEyeSize(side, next) - : updateAvatarEyeDimension(side, dimension, next) - } - /> - ) - })} -
-
- ))} - -
-
-

{t('Position et espacement')}

-

- {t('Coordonnées propres à l’avatar, indépendantes des poses.')} -

-
- - setLinked(current => ({ ...current, position: !current.position })) - } - /> -
-
- {(['Left', 'Right'] as Side[]).map(side => ( -
-

{t(side === 'Left' ? 'Œil gauche' : 'Œil droit')}

- - updateHighlight( - active - ? linked.position - ? 'both' - : side === 'Left' - ? 'left' - : 'right' - : null - ) - } - onChange={value => updateAvatarEyePosition(side, 'X', value)} - /> - - updateHighlight( - active - ? linked.position - ? 'both' - : side === 'Left' - ? 'left' - : 'right' - : null - ) - } - onChange={value => updateAvatarEyePosition(side, 'Y', value)} - /> -
- ))} -
-
- updateHighlight(active ? 'both' : null)} - onChange={spacing => updateAvatarEyes({ spacing })} - /> -
-
- -
- - - setLinked(current => ({ ...current, rotation: !current.rotation })) - } - /> -
-
- - updateHighlight(active ? (linked.rotation ? 'both' : 'left') : null) - } - onChange={leftAngle => - updateAvatarEyes({ - leftAngle, - ...(linked.rotation ? { rightAngle: -leftAngle } : {}), - }) - } - /> - - updateHighlight(active ? (linked.rotation ? 'both' : 'right') : null) - } - onChange={rightAngle => - updateAvatarEyes({ - rightAngle, - ...(linked.rotation ? { leftAngle: -rightAngle } : {}), - }) - } - /> -
-
- - - updateAvatarColors({ eyes })} - /> - -
- - )} - {!bodyEditing && ( - <> - - - - updateImmediate({ ...expression, bodyColor })} - /> - {expression.bodyColor && ( - - )} - - - - updateHighlight(active ? 'head' : null)} - onChange={value => updateImmediate({ ...expression, headX: value })} - /> - updateHighlight(active ? 'head' : null)} - onChange={value => updateImmediate({ ...expression, headY: value })} - /> - updateHighlight(active ? 'head' : null)} - onChange={value => updateImmediate({ ...expression, headZ: value })} - /> - - - - - - updateImmediate({ ...expression, eyeColor })} - /> - {expression.eyeColor && ( - - )} - - {(['width', 'height', 'size'] as const).map(dimension => ( - -
-

- {t( - { - width: 'Largeur', - height: 'Hauteur', - size: 'Taille proportionnelle', - }[dimension] - )} -

- - setLinked(current => ({ - ...current, - [dimension]: !current[dimension], - })) - } - /> -
-
- {(['Left', 'Right'] as Side[]).map(side => { - const width = expression[`width${side}`] - const height = expression[`height${side}`] - const value = - dimension === 'width' - ? width - : dimension === 'height' - ? height - : Math.max(width, height) - return ( - - updateHighlight( - active - ? linked[dimension] - ? 'both' - : side === 'Left' - ? 'left' - : 'right' - : null - ) - } - onChange={next => - dimension === 'size' - ? updateSize(side, next) - : updateDimension(side, dimension, next) - } - /> - ) - })} -
-
- ))} - - -
-
-

{t('Œil gauche')}

- updateHighlight(active ? 'left' : null)} - onChange={value => - updateImmediate({ ...expression, positionXLeft: value }) - } - /> - updateHighlight(active ? 'left' : null)} - onChange={value => - updateImmediate({ ...expression, positionYLeft: value }) - } - /> -
-
-

{t('Œil droit')}

- updateHighlight(active ? 'right' : null)} - onChange={value => - updateImmediate({ ...expression, positionXRight: value }) - } - /> - updateHighlight(active ? 'right' : null)} - onChange={value => - updateImmediate({ ...expression, positionYRight: value }) - } - /> -
-
-
- updateHighlight(active ? 'both' : null)} - onChange={updateSpacing} - /> -
-
- -
- - - setLinked(current => ({ ...current, rotation: !current.rotation })) - } - /> -
-
- - updateHighlight(active ? (linked.rotation ? 'both' : 'left') : null) - } - onChange={value => - updateImmediate({ - ...expression, - leftAngle: value, - ...(linked.rotation ? { rightAngle: -value } : {}), - }) - } - /> - - updateHighlight(active ? (linked.rotation ? 'both' : 'right') : null) - } - onChange={value => - updateImmediate({ - ...expression, - rightAngle: value, - ...(linked.rotation ? { leftAngle: -value } : {}), - }) - } - /> -
-
-
- - - - updateImmediate({ ...expression, perspective: value })} - /> -
- {t('Afficher le maillage')} - -
- -
-
- - )} -
- )} - - {!sequenceEditing && !editing && bodyEditing && ( -
-
- - -
- -
- )} - - {!sequenceEditing && !editing && !bodyEditing && mode === 'expressions' && ( -
- -
-
-

{expressions.length} presets

-

{t('Expressions')}

-
- {t('Double-clic pour modifier')} -
-
- {expressions.map((preset, index) => ( - - transitionToExpression(preset, index)} - onEdit={() => openExpressionEditor(index, preset)} - onDuplicate={() => duplicateExpression(index, preset)} - onDelete={() => { - openExpressionEditor(index, preset) - setDeleteExpressionOpen(true) - }} - draggable - onDragStart={event => { - expressionDragOrigin.current = expressions - expressionDragPreview.current = expressions - draggedExpressionId.current = preset.id - setDraggingExpressionId(preset.id) - event.dataTransfer.effectAllowed = 'move' - }} - onDragEnter={() => previewExpressionMove(preset.id)} - onDragOver={event => { - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - }} - onDrop={event => { - event.preventDefault() - commitExpressionMove(preset.id) - }} - onDragEnd={cancelExpressionMove} - /> - - ))} - -
-
- - - { - springSpeedRef.current = value - setSpringSpeed(value) - }} - /> -
- - -
-
-
- )} - - {!sequenceEditing && !editing && !bodyEditing && mode === 'states' && ( -
- -
-
-

- {sequences.length} {t('animations')} -

-

{t('Animations')}

-
-
-
- {groupSequences(sequences).map(group => ( -
event.preventDefault()} - onDrop={event => { - event.preventDefault() - commitStateMove(null, group.name) - }} - > - - {group.sequences.every(sequence => sequence.builtIn) - ? t(group.name) - : group.name} - -
- {group.sequences.map(sequence => { - const firstStep = sequence.steps[0] - const firstExpression = firstStep - ? expressionById.get(firstStep.expressionId) - : undefined - const card = ( - - ) - return ( - - - - - openSequenceEditor(sequence)}> - - {t('Modifier')} - - duplicateState(sequence)}> - - {t('Dupliquer')} - - - { - openSequenceEditor(sequence) - setDeleteSequenceOpen(true) - }} - > - - {t('Supprimer')} - - - - - ) - })} -
-
- ))} -
- -
-
-
-
- )} - - {!sequenceEditing && !editing && !bodyEditing && mode === 'export' && ( - - - -
- -
- {t('Avatar sélectionné')} - {activeAvatar.name} -
-
-
- - - -
- - -
-
- - -
-
-

- {selectedExportAnimations.length}/{sequences.length} {t('sélectionnées')} -

-

{t('Animations à exporter')}

-
- -
-
- {sequences.map(animation => { - const firstStep = animation.steps[0] - const firstExpression = firstStep - ? expressionById.get(firstStep.expressionId) - : undefined - return ( - - ) - })} -
-
- - -
- - - - -
- {t('Aperçu du mode photo')} - {activeAvatar.name} - - {snapshotSize} × {snapshotSize} px · {snapshotFormat.toUpperCase()} - -
-
- - - - - {t('Style')} - - - {snapshotBackground !== 'transparent' && ( -
- - {(snapshotBackground === 'linear' || snapshotBackground === 'radial') && ( - - )} -
- )} -
- - - -
- {t('Format d’export')} - {t('Choisis le type de fichier généré par le mode photo.')} -
- -
- - -
- {t('Définition')} - {t('Dimensions du fichier exporté.')} -
- -
-
-
- - -
- - - { - prepareStudioProjectImport(event.currentTarget.files?.[0]) - event.currentTarget.value = '' - }} - /> -
- {projectImportError && ( -

- {projectImportError} -

- )} -
-
- )} - {activeSequence && !editorPageOpen && ( - - - {statePlayerExpanded && ( - -
-
-

{t('Détails de l’animation')}

-

{activeSequenceLabel}

-

- {activeSequence.builtIn - ? t(activeSequence.description) - : activeSequence.description} -

-
- - {t('Mode de lecture')} · {t(activeSequence.playbackMode)} - -
-
-
- {t('Expressions')} - {activeSequence.steps.length} - - {activeSequence.steps - .map(step => formatSeconds(step.holdMs, language)) - .join(' · ')} - -
-
- {t('Premier clignement')} - - {activeSequence.blink.enabled - ? formatSeconds(activeSequence.blink.initialDelayMs, language) - : t('Désactivé')} - - {t('après le lancement')} -
-
- {t('Intervalle du clignement')} - - {formatSeconds(activeSequence.blink.minIntervalMs, language)}– - {formatSeconds(activeSequence.blink.maxIntervalMs, language)} - - {t('tirage aléatoire')} -
-
- {t('Durée du clignement')} - {activeSequence.blink.durationMs} ms - {t('fermeture et ouverture')} -
-
-
- )} -
-
- {activeSequence.steps.map((step, position) => { - const expressionIndex = findExpressionIndex(expressions, step.expressionId) - const preset = expressions[expressionIndex] - if (!preset) return null - return ( - - ) - })} -
-
- -
-
-
- )} -
- - - - {t(`Supprimer ${activeAvatar.name} ?`)} - - {t( - 'Le corps, les expressions et les animations propres à cet avatar seront définitivement supprimés. La bibliothèque de base sera conservée.' - )} - - - - {t('Annuler')} - {t('Supprimer')} - - - - - - - {t('Supprimer cette expression ?')} - - {t( - 'Cette action retirera définitivement le preset de la bibliothèque de cet avatar.' - )} - - {animationsAffectedByExpressionDeletion.length > 0 && ( -
-

- {t('Cette expression sera aussi retirée des animations suivantes :')} -

-
- {animationsAffectedByExpressionDeletion.map(sequence => ( - - {sequence.name} - - ))} -
-

- {t( - 'Si une animation ne contient que cette expression, l’expression de repli lui sera assignée pour qu’elle reste jouable.' - )} -

-
- )} -
- - {t('Annuler')} - {t('Supprimer')} - -
-
- - - - {t('Supprimer cette animation ?')} - - {t('Cette action supprimera définitivement cette animation.')} - - - - {t('Annuler')} - {t('Supprimer')} - - - - { - if (!open) setPendingProjectImport(null) - }} - > - - - {t('Importer ce projet ?')} - - {t( - 'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.' - )}{' '} - {pendingProjectImport?.fileName} - - - - {t('Annuler')} - - {t('Importer')} - - - - -
- ) -} - -function ControlSection({ - title, - subtitle, - compact = false, - children, -}: { - title: string - subtitle: string - compact?: boolean - children: React.ReactNode -}) { - const { t } = useStudioLanguage() - return ( -
-
-

{t(title)}

-

{t(subtitle)}

-
- -
{children}
-
- ) -} - -function SnapshotPreview({ - scene, - colors, - background, - colorFrom, - colorTo, -}: { - scene: RenderedScene - colors: RenderedColors - background: SnapshotBackground - colorFrom: string - colorTo: string -}) { - const id = useId().replace(/:/g, '') - const clipId = `${id}-clip` - const linearId = `${id}-linear` - const radialId = `${id}-radial` - const backgroundFill = - background === 'solid' - ? colorFrom - : background === 'linear' - ? `url(#${linearId})` - : `url(#${radialId})` - - return ( -
- -
- ) -} - -function ExportSection({ - value, - title, - subtitle, - children, -}: { - value: string - title: string - subtitle: string - children: React.ReactNode -}) { - const { t } = useStudioLanguage() - return ( - - - - {t(title)} - {t(subtitle)} - - - -
{children}
-
-
- ) -} - -function InspectorCard({ className, ...props }: React.ComponentProps) { - return -} - -function StatePlayer({ - name, - status, - onToggle, - onStop, -}: { - name: string | null - status: PlaybackStatus - onToggle: () => void - onStop: () => void -}) { - const { t } = useStudioLanguage() - if (!name) return null - const statusLabel = - status === 'playing' ? 'En lecture' : status === 'paused' ? 'En pause' : 'Arrêté' - return ( -
- - - -
- ) -} - -function PlaybackIdentity({ name, status }: { name: string; status: PlaybackStatus }) { - const { t } = useStudioLanguage() - const statusLabel = - status === 'playing' ? 'En lecture' : status === 'paused' ? 'En pause' : 'Arrêté' - return ( - - - - {t(statusLabel)} - {name} - - - ) -} - -function PanelTitle({ - title, - subtitle, - level = 2, -}: { - title: string - subtitle: string - level?: 2 | 3 -}) { - const { t } = useStudioLanguage() - const Heading = level === 3 ? 'h3' : 'h2' - return ( - - {t(title)} - {t(subtitle)} - - ) -} - -export default function App() { - return ( - - - - ) -} diff --git a/src/app/App.tsx b/src/app/App.tsx new file mode 100644 index 0000000..091de2b --- /dev/null +++ b/src/app/App.tsx @@ -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 +} + +export default function App() { + return ( + + + + ) +} diff --git a/src/app/components/common.tsx b/src/app/components/common.tsx new file mode 100644 index 0000000..7397ca8 --- /dev/null +++ b/src/app/components/common.tsx @@ -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 ( +
+
+

{t(title)}

+

{t(subtitle)}

+
+ +
{children}
+
+ ) +} + +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 ( +
+ +
+ ) +} + +export function ExportSection({ + value, + title, + subtitle, + children, +}: { + value: string + title: string + subtitle: string + children: React.ReactNode +}) { + const { t } = useStudioLanguage() + return ( + + + + {t(title)} + {t(subtitle)} + + + +
{children}
+
+
+ ) +} + +export function InspectorCard({ className, ...props }: React.ComponentProps) { + return +} + +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 ( +
+ + + +
+ ) +} + +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 ( + + + + {t(statusLabel)} + {name} + + + ) +} + +export function PanelTitle({ + title, + subtitle, + level = 2, +}: { + title: string + subtitle: string + level?: 2 | 3 +}) { + const { t } = useStudioLanguage() + const Heading = level === 3 ? 'h3' : 'h2' + return ( + + {t(title)} + {t(subtitle)} + + ) +} diff --git a/src/app/components/controls.tsx b/src/app/components/controls.tsx new file mode 100644 index 0000000..3e8a28e --- /dev/null +++ b/src/app/components/controls.tsx @@ -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 ( + + {translatedLabel} + + onChange(event.currentTarget.value)} + /> + { + const next = event.currentTarget.value.toUpperCase() + if (/^#[0-9A-F]{6}$/.test(next)) onChange(next.toLowerCase()) + }} + onBlur={event => { + if (!/^#[0-9A-F]{6}$/.test(event.currentTarget.value.toUpperCase())) { + event.currentTarget.value = value.toUpperCase() + } + }} + /> + + + ) +} + +export function AmbientMotionField({ + label, + value, + options, + onChange, +}: { + label: string + value: Motion + options: { value: Motion; label: string }[] + onChange: (value: Motion) => void +}) { + const { t } = useStudioLanguage() + return ( + + {t(label)} + + + ) +} + +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) => { + event.preventDefault() + onActiveChange?.(true) + dragRef.current = { x: event.clientX, value } + event.currentTarget.setPointerCapture(event.pointerId) + } + + const scrub = (event: React.PointerEvent) => { + if (!dragRef.current || !event.currentTarget.hasPointerCapture(event.pointerId)) return + const speed = event.shiftKey ? 10 : event.altKey ? 0.1 : 1 + const next = dragRef.current.value + (event.clientX - dragRef.current.x) * step * speed + onChange(bounded(next, min, max)) + } + + const stopScrub = () => { + dragRef.current = null + onActiveChange?.(false) + } + + return ( + + + + + ) +} + +export function LinkButton({ + linked, + onClick, + label, +}: { + linked: boolean + onClick: () => void + label: string +}) { + const { t } = useStudioLanguage() + return ( + + ) +} diff --git a/src/app/studio-utils.ts b/src/app/studio-utils.ts new file mode 100644 index 0000000..87c96db --- /dev/null +++ b/src/app/studio-utils.ts @@ -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 }> + > +>() + +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 +) => { + 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, +}) diff --git a/src/styles.css b/src/app/styles.css similarity index 100% rename from src/styles.css rename to src/app/styles.css diff --git a/src/__tests__/playback-test.ts b/src/features/animation/__tests__/playback-test.ts similarity index 92% rename from src/__tests__/playback-test.ts rename to src/features/animation/__tests__/playback-test.ts index a6fe56a..d221159 100644 --- a/src/__tests__/playback-test.ts +++ b/src/features/animation/__tests__/playback-test.ts @@ -1,4 +1,4 @@ -import { createInitialSequences } from '../sequences' +import { createInitialSequences } from '@/features/animation/sequences' import { advancePlaybackTimeline, beginPlayback, @@ -6,7 +6,7 @@ import { pausePlaybackTimeline, schedulePlaybackBlink, schedulePlaybackStep, -} from '../playback' +} from '@/features/animation/playback' describe('State playback timeline', () => { it('preserves the current sequence position when the avatar changes', () => { diff --git a/src/__tests__/sequences-test.ts b/src/features/animation/__tests__/sequences-test.ts similarity index 97% rename from src/__tests__/sequences-test.ts rename to src/features/animation/__tests__/sequences-test.ts index 6c5b694..c7cb909 100644 --- a/src/__tests__/sequences-test.ts +++ b/src/features/animation/__tests__/sequences-test.ts @@ -5,8 +5,8 @@ import { normalizeSequencesForExpressions, parseSequences, remapSequencesAfterExpressionDelete, -} from '../sequences' -import { initialExpressions } from '../presets' +} from '@/features/animation/sequences' +import { initialExpressions } from '@/features/avatar/presets' describe('editable avatar sequences', () => { it('creates the idle animation with editable steps and blink settings', () => { diff --git a/src/features/animation/components/SequenceWorkspace.tsx b/src/features/animation/components/SequenceWorkspace.tsx new file mode 100644 index 0000000..c73762d --- /dev/null +++ b/src/features/animation/components/SequenceWorkspace.tsx @@ -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 + reduceMotion: boolean + onSelectedStepChange: (id: string | null) => void + onChange: (draft: AvatarSequence) => void + onPreviewStep: (step: SequenceStep) => void + onEditExpression: (index: number, expression: Expression) => void + onPlay: () => void + onPause: () => void + onStop: () => void + playing: boolean + active: boolean + onCancel: () => void + onSave: () => void + onDuplicate: () => void + onDelete: () => void +}) { + const { t } = useStudioLanguage() + const draggedStepId = useRef(null) + const selectedStep = + editing.draft.steps.find(step => step.id === selectedStepId) ?? editing.draft.steps[0] + + const updateStep = (changes: Partial) => { + if (!selectedStep) return + onChange({ + ...editing.draft, + steps: editing.draft.steps.map(step => + step.id === selectedStep.id ? { ...step, ...changes } : step + ), + }) + } + + const moveStep = (targetId: string) => { + const draggedId = draggedStepId.current + if (!draggedId || draggedId === targetId) return + const dragged = editing.draft.steps.find(step => step.id === draggedId) + const targetIndex = editing.draft.steps.findIndex(step => step.id === targetId) + if (!dragged || targetIndex < 0) return + const next = editing.draft.steps.filter(step => step.id !== draggedId) + next.splice(targetIndex, 0, dragged) + onChange({ ...editing.draft, steps: next }) + } + + return ( + <> +
+ +
+

{t('Éditeur d’animation')}

+

{editing.sourceId ? t('Modifier l’animation') : t('Nouvelle animation')}

+

{t('Compose les expressions, leur cadence et les clignements de cette animation.')}

+
+
+ + {active && ( + + )} +
+
+ +
+ + + + {t('Nom')} + onChange({ ...editing.draft, name: event.currentTarget.value })} + /> + + + {t('Catégorie')} + + + + {t('Description')} + + onChange({ ...editing.draft, description: event.currentTarget.value }) + } + /> + + + {t('Mode de lecture')} + + + + + + + + {editing.draft.steps.length ? ( +
+ {editing.draft.steps.map((step, position) => { + const expressionIndex = findExpressionIndex(expressions, step.expressionId) + const preset = expressions[expressionIndex] + if (!preset) return null + const card = ( + + ) + return ( + { + draggedStepId.current = step.id + onSelectedStepChange(step.id) + }} + onDragEnter={() => moveStep(step.id)} + onDragOver={event => event.preventDefault()} + onDragEnd={() => { + draggedStepId.current = null + }} + > + + + + onEditExpression(expressionIndex, preset)} + > + {t('Modifier')} + + + + + ) + })} +
+ ) : ( +

{t('Ajoute une expression pour commencer.')}

+ )} + + {selectedStep ? ( +
+
+
+

{t('Étape sélectionnée')}

+

{t('Durée visible avant de passer à l’expression suivante.')}

+
+ +
+ updateStep({ holdMs })} + /> + updateStep({ transitionMs })} + /> + + {t('Transition')} + + +
+ ) : null} +
+ + + +
+ {expressions.map((preset, index) => ( + + ))} +
+
+
+ + + +
+ {t('Activer les clignements')} + + onChange({ + ...editing.draft, + blink: { ...editing.draft.blink, enabled }, + }) + } + /> +
+ + onChange({ + ...editing.draft, + blink: { ...editing.draft.blink, initialDelayMs }, + }) + } + /> +
+ + onChange({ + ...editing.draft, + blink: { + ...editing.draft.blink, + minIntervalMs, + maxIntervalMs: Math.max(minIntervalMs, editing.draft.blink.maxIntervalMs), + }, + }) + } + /> + + onChange({ + ...editing.draft, + blink: { ...editing.draft.blink, maxIntervalMs }, + }) + } + /> +
+ + onChange({ + ...editing.draft, + blink: { ...editing.draft.blink, durationMs }, + }) + } + /> +
+
+
+ +
+
+ {editing.sourceId && ( + + )} + +
+ +
+ + ) +} diff --git a/src/playback.ts b/src/features/animation/playback.ts similarity index 100% rename from src/playback.ts rename to src/features/animation/playback.ts diff --git a/src/sequences.ts b/src/features/animation/sequences.ts similarity index 99% rename from src/sequences.ts rename to src/features/animation/sequences.ts index d02b75a..29c3c8d 100644 --- a/src/sequences.ts +++ b/src/features/animation/sequences.ts @@ -4,8 +4,8 @@ import { stateGroups, stateNotes, statePools, -} from './presets' -import type { Expression } from './geometry' +} from '../avatar/presets' +import type { Expression } from '../avatar/geometry' export type SequencePlaybackMode = 'loop' | 'once' | 'pingPong' export type SequenceTransition = 'spring' | 'smooth' | 'snappy' diff --git a/src/__tests__/ambient-motion-test.ts b/src/features/avatar/__tests__/ambient-motion-test.ts similarity index 91% rename from src/__tests__/ambient-motion-test.ts rename to src/features/avatar/__tests__/ambient-motion-test.ts index 8ebdcdc..bcce0ef 100644 --- a/src/__tests__/ambient-motion-test.ts +++ b/src/features/avatar/__tests__/ambient-motion-test.ts @@ -1,5 +1,9 @@ -import { ambientBodyOffset, applyAmbientMotion, hasAmbientMotion } from '../ambientMotion' -import { defaultExpression } from '../presets' +import { + ambientBodyOffset, + applyAmbientMotion, + hasAmbientMotion, +} from '@/features/avatar/ambientMotion' +import { defaultExpression } from '@/features/avatar/presets' describe('perpetual expression motion', () => { it('leaves a motionless expression unchanged', () => { diff --git a/src/__tests__/avatars-test.ts b/src/features/avatar/__tests__/avatars-test.ts similarity index 89% rename from src/__tests__/avatars-test.ts rename to src/features/avatar/__tests__/avatars-test.ts index 612bd9a..ded9e96 100644 --- a/src/__tests__/avatars-test.ts +++ b/src/features/avatar/__tests__/avatars-test.ts @@ -1,5 +1,5 @@ -import { defaultExpression } from '../presets' -import { createInitialSequences } from '../sequences' +import { defaultExpression } from '@/features/avatar/presets' +import { createInitialSequences } from '@/features/animation/sequences' import { applyAvatarEyeDefaults, cloneAvatarBehavior, @@ -7,8 +7,8 @@ import { defaultAvatarEyes, parseAvatarEyeDefaults, resolveAvatarBehavior, -} from '../avatars' -import { initialExpressions } from '../presets' +} from '@/features/avatar/avatars' +import { initialExpressions } from '@/features/avatar/presets' describe('avatar eye defaults', () => { it('keeps the historical rendering when using default values', () => { diff --git a/src/__tests__/expression-editing-test.ts b/src/features/avatar/__tests__/expression-editing-test.ts similarity index 82% rename from src/__tests__/expression-editing-test.ts rename to src/features/avatar/__tests__/expression-editing-test.ts index 88c3567..46f49f0 100644 --- a/src/__tests__/expression-editing-test.ts +++ b/src/features/avatar/__tests__/expression-editing-test.ts @@ -1,5 +1,9 @@ -import { updateEyeDimension, scaleEye, updateEyePosition } from '../expressionEditing' -import { defaultExpression } from '../presets' +import { + updateEyeDimension, + scaleEye, + updateEyePosition, +} from '@/features/avatar/expressionEditing' +import { defaultExpression } from '@/features/avatar/presets' describe('Expression eye editing', () => { it('applies linked dimensions through one invariant-preserving operation', () => { diff --git a/src/__tests__/geometry-editor-test.ts b/src/features/avatar/__tests__/geometry-editor-test.ts similarity index 93% rename from src/__tests__/geometry-editor-test.ts rename to src/features/avatar/__tests__/geometry-editor-test.ts index dbe65e9..b7417cb 100644 --- a/src/__tests__/geometry-editor-test.ts +++ b/src/features/avatar/__tests__/geometry-editor-test.ts @@ -1,4 +1,4 @@ -import type { BodyNode } from '../body' +import type { BodyNode } from '@/features/avatar/body' import { poseFromExpression, renderAvatar, @@ -6,9 +6,9 @@ import { rotateBodyNodeAroundLocalAxis, translateBodyNodeInCameraPlane, translateBodyNodeAlongLocalAxis, -} from '../geometry' -import { defaultExpression } from '../presets' -import { surfacePresets } from '../surfaces' +} from '@/features/avatar/geometry' +import { defaultExpression } from '@/features/avatar/presets' +import { surfacePresets } from '@/features/avatar/surfaces' const node: BodyNode = { id: 'shape-test', diff --git a/src/__tests__/manipulation-session-test.ts b/src/features/avatar/__tests__/manipulation-session-test.ts similarity index 96% rename from src/__tests__/manipulation-session-test.ts rename to src/features/avatar/__tests__/manipulation-session-test.ts index 7b8d4d4..eadc5a2 100644 --- a/src/__tests__/manipulation-session-test.ts +++ b/src/features/avatar/__tests__/manipulation-session-test.ts @@ -3,7 +3,7 @@ import { beginManipulationFromRenderedValue, finishManipulation, previewManipulation, -} from '../manipulationSession' +} from '@/features/avatar/manipulationSession' describe('pointer manipulation session', () => { it('previews many values and commits exactly once', () => { diff --git a/src/__tests__/presets-test.ts b/src/features/avatar/__tests__/presets-test.ts similarity index 93% rename from src/__tests__/presets-test.ts rename to src/features/avatar/__tests__/presets-test.ts index 563884e..341f350 100644 --- a/src/__tests__/presets-test.ts +++ b/src/features/avatar/__tests__/presets-test.ts @@ -1,4 +1,4 @@ -import { getStatePlaybackConfig } from '../presets' +import { getStatePlaybackConfig } from '@/features/avatar/presets' describe('state playback configuration', () => { it('keeps idle slower than an active sequence', () => { diff --git a/src/ambientMotion.ts b/src/features/avatar/ambientMotion.ts similarity index 100% rename from src/ambientMotion.ts rename to src/features/avatar/ambientMotion.ts diff --git a/src/avatars.ts b/src/features/avatar/avatars.ts similarity index 98% rename from src/avatars.ts rename to src/features/avatar/avatars.ts index 4d1ff7f..03d5b96 100644 --- a/src/avatars.ts +++ b/src/features/avatar/avatars.ts @@ -3,7 +3,11 @@ import { defaultExpression, initialExpressions } from './presets' import { surfacePresets } from './surfaces' import type { Expression } from './geometry' import { isBodyMotion, isEyeMotion } from './ambientMotion' -import { normalizeSequencesForExpressions, parseSequences, type AvatarSequence } from './sequences' +import { + normalizeSequencesForExpressions, + parseSequences, + type AvatarSequence, +} from '../animation/sequences' export type AvatarBehaviorLibrary = { expressions: Expression[] diff --git a/src/body.ts b/src/features/avatar/body.ts similarity index 100% rename from src/body.ts rename to src/features/avatar/body.ts diff --git a/src/features/avatar/components/ExpressionWorkspace.tsx b/src/features/avatar/components/ExpressionWorkspace.tsx new file mode 100644 index 0000000..b61b80b --- /dev/null +++ b/src/features/avatar/components/ExpressionWorkspace.tsx @@ -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 ( + + ) +} + +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 ( + + ) +} + +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) => void + onDragEnter?: () => void + onDragOver?: (event: React.DragEvent) => void + onDrop?: (event: React.DragEvent) => void + onDragEnd?: () => void +}) { + const { t } = useStudioLanguage() + const card = ( + + ) + if (!onEdit) return card + return ( + + + + + {t('Modifier')} + + {onDuplicate && ( + + {t('Dupliquer')} + + )} + {onDelete && ( + <> + + + {t('Supprimer')} + + + )} + + + ) +} + +export function ExpressionWorkspace({ + editing, + avatarColors, + backButtonRef, + onChange, + onCancel, + onSave, + onDuplicate, + onDelete, +}: { + editing: { index: number | null; draft: Expression } + avatarColors: AvatarColors + backButtonRef: RefObject + onChange: (draft: Expression) => void + onCancel: () => void + onSave: () => void + onDuplicate: () => void + onDelete: () => void +}) { + const { t } = useStudioLanguage() + const [linked, setLinked] = useState({ + width: true, + height: true, + size: true, + rotation: true, + }) + const update = (changes: Partial) => onChange({ ...editing.draft, ...changes }) + const updateDimension = (side: Side, dimension: 'width' | 'height', value: number) => { + onChange(updateEyeDimension(editing.draft, side, dimension, value, linked[dimension])) + } + const updateSize = (side: Side, value: number) => { + onChange(scaleEye(editing.draft, side, value, linked.size)) + } + const updateRotation = (side: Side, value: number) => { + onChange({ + ...editing.draft, + [side === 'Left' ? 'leftAngle' : 'rightAngle']: value, + ...(linked.rotation ? { [side === 'Left' ? 'rightAngle' : 'leftAngle']: -value } : {}), + }) + } + + return ( + <> +
+ +
+

{t('Preset en mémoire')}

+

+ {editing.index === null + ? t('Nouvelle expression') + : t(`Modifier l’expression ${String(editing.index).padStart(2, '0')}`)} +

+

{t('L’avatar à gauche affiche cette expression en direct.')}

+
+
+
+
+ + +

{t('Couleur du corps')}

+ update({ bodyColor })} + /> + {editing.draft.bodyColor && ( + + )} +
+ +

{t('Mouvement perpétuel')}

+ update({ bodyMotion })} + /> +

+ {t('Ajoute une légère présence ou un tremblement continu au corps.')} +

+
+ +

{t('Rotation de la tête')}

+ {(['headX', 'headY', 'headZ'] as const).map(field => ( + update({ [field]: value })} + /> + ))} +
+
+ + +

{t('Couleur des yeux')}

+ update({ eyeColor })} + /> + {editing.draft.eyeColor && ( + + )} +
+ +

{t('Mouvement perpétuel')}

+ update({ eyeMotion })} + /> +

+ {t('Anime le regard par petites saccades naturelles ou par tremblement.')} +

+
+ {(['width', 'height', 'size'] as const).map(dimension => ( + +
+

+ {t( + { width: 'Largeur', height: 'Hauteur', size: 'Taille proportionnelle' }[ + dimension + ] + )} +

+ + setLinked(current => ({ ...current, [dimension]: !current[dimension] })) + } + /> +
+
+ {(['Left', 'Right'] as Side[]).map(side => { + const width = editing.draft[`width${side}`] + const height = editing.draft[`height${side}`] + const value = + dimension === 'width' + ? width + : dimension === 'height' + ? height + : Math.max(width, height) + return ( + + dimension === 'size' + ? updateSize(side, next) + : updateDimension(side, dimension, next) + } + /> + ) + })} +
+
+ ))} + +

{t('Position et espacement')}

+
+ {(['Left', 'Right'] as Side[]).map(side => ( +
+

{t(side === 'Left' ? 'Œil gauche' : 'Œil droit')}

+ update({ [`positionX${side}`]: value })} + /> + update({ [`positionY${side}`]: value })} + /> +
+ ))} +
+
+ update({ spacing: value })} + /> +
+
+ +
+

{t('Rotation locale')}

+ + setLinked(current => ({ ...current, rotation: !current.rotation })) + } + /> +
+
+ updateRotation('Left', value)} + /> + updateRotation('Right', value)} + /> +
+
+
+ + + update({ perspective: value })} + /> + + +
+
+
+
+ {editing.index !== null && ( + + )} + +
+
+ +
+
+ + ) +} diff --git a/src/expressionEditing.ts b/src/features/avatar/expressionEditing.ts similarity index 100% rename from src/expressionEditing.ts rename to src/features/avatar/expressionEditing.ts diff --git a/src/geometry.ts b/src/features/avatar/geometry.ts similarity index 100% rename from src/geometry.ts rename to src/features/avatar/geometry.ts diff --git a/src/manipulationSession.ts b/src/features/avatar/manipulationSession.ts similarity index 100% rename from src/manipulationSession.ts rename to src/features/avatar/manipulationSession.ts diff --git a/src/presets.ts b/src/features/avatar/presets.ts similarity index 100% rename from src/presets.ts rename to src/features/avatar/presets.ts diff --git a/src/surfaces.ts b/src/features/avatar/surfaces.ts similarity index 100% rename from src/surfaces.ts rename to src/features/avatar/surfaces.ts diff --git a/src/__tests__/exporter-test.ts b/src/features/export/__tests__/exporter-test.ts similarity index 96% rename from src/__tests__/exporter-test.ts rename to src/features/export/__tests__/exporter-test.ts index d6cf933..1fff5c1 100644 --- a/src/__tests__/exporter-test.ts +++ b/src/features/export/__tests__/exporter-test.ts @@ -1,4 +1,4 @@ -import { createAvatar } from '../avatars' +import { createAvatar } from '@/features/avatar/avatars' import { parse } from '@babel/parser' import { createAvatarExportPayload, @@ -8,9 +8,9 @@ import { generateReactAvatarComponent, generateReactAvatarPackage, generateReactAvatarRuntime, -} from '../exporter' -import { initialExpressions } from '../presets' -import { createInitialSequences } from '../sequences' +} from '@/features/export/exporter' +import { initialExpressions } from '@/features/avatar/presets' +import { createInitialSequences } from '@/features/animation/sequences' describe('avatar export', () => { const avatar = createAvatar('Strobi') diff --git a/src/__tests__/snapshot-exporter-test.ts b/src/features/export/__tests__/snapshot-exporter-test.ts similarity index 77% rename from src/__tests__/snapshot-exporter-test.ts rename to src/features/export/__tests__/snapshot-exporter-test.ts index ba2fb8c..5d60ad9 100644 --- a/src/__tests__/snapshot-exporter-test.ts +++ b/src/features/export/__tests__/snapshot-exporter-test.ts @@ -1,8 +1,8 @@ -import { poseFromExpression, renderAvatar } from '../geometry' -import { defaultExpression } from '../presets' -import { createRenderedScene, paintRenderedOffset } from '../renderedScene' -import { serializeAvatarSnapshot, snapshotFileName } from '../snapshotExporter' -import { surfacePresets } from '../surfaces' +import { poseFromExpression, renderAvatar } from '@/features/avatar/geometry' +import { defaultExpression } from '@/features/avatar/presets' +import { createRenderedScene, paintRenderedOffset } from '@/features/rendering/renderedScene' +import { serializeAvatarSnapshot, snapshotFileName } from '@/features/export/snapshotExporter' +import { surfacePresets } from '@/features/avatar/surfaces' describe('avatar snapshot export', () => { const geometry = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1) diff --git a/src/exporter.ts b/src/features/export/exporter.ts similarity index 98% rename from src/exporter.ts rename to src/features/export/exporter.ts index 92ce246..f373d42 100644 --- a/src/exporter.ts +++ b/src/features/export/exporter.ts @@ -1,8 +1,8 @@ -import { applyAvatarEyeDefaults, type StudioAvatar } from './avatars' -import type { Expression } from './geometry' -import { translateStudioText, type StudioLanguage } from './i18n' +import { applyAvatarEyeDefaults, type StudioAvatar } from '../avatar/avatars' +import type { Expression } from '../avatar/geometry' +import { translateStudioText, type StudioLanguage } from '../../i18n' import { proceduralBrowserRuntime } from './proceduralBrowserRuntime' -import type { AvatarSequence } from './sequences' +import type { AvatarSequence } from '../animation/sequences' import { standaloneEngineSource } from './standaloneEngine.generated' import { createStoredZip } from './storedZip' diff --git a/src/proceduralBrowserRuntime.ts b/src/features/export/proceduralBrowserRuntime.ts similarity index 100% rename from src/proceduralBrowserRuntime.ts rename to src/features/export/proceduralBrowserRuntime.ts diff --git a/src/snapshotExporter.ts b/src/features/export/snapshotExporter.ts similarity index 96% rename from src/snapshotExporter.ts rename to src/features/export/snapshotExporter.ts index 7a373d7..2d454af 100644 --- a/src/snapshotExporter.ts +++ b/src/features/export/snapshotExporter.ts @@ -1,5 +1,5 @@ -import type { AvatarColors } from './avatars' -import type { RenderedScene } from './renderedScene' +import type { AvatarColors } from '../avatar/avatars' +import type { RenderedScene } from '../rendering/renderedScene' export type SnapshotBackground = 'transparent' | 'solid' | 'linear' | 'radial' diff --git a/src/standaloneEngine.entry.ts b/src/features/export/standaloneEngine.entry.ts similarity index 68% rename from src/standaloneEngine.entry.ts rename to src/features/export/standaloneEngine.entry.ts index 3c09894..18f8ac6 100644 --- a/src/standaloneEngine.entry.ts +++ b/src/features/export/standaloneEngine.entry.ts @@ -1,2 +1,2 @@ -export { expressionFields, poseFromExpression, renderAvatar } from './geometry' -export { ambientBodyOffset, applyAmbientMotion, hasAmbientMotion } from './ambientMotion' +export { expressionFields, poseFromExpression, renderAvatar } from '../avatar/geometry' +export { ambientBodyOffset, applyAmbientMotion, hasAmbientMotion } from '../avatar/ambientMotion' diff --git a/src/standaloneEngine.generated.ts b/src/features/export/standaloneEngine.generated.ts similarity index 100% rename from src/standaloneEngine.generated.ts rename to src/features/export/standaloneEngine.generated.ts diff --git a/src/storedZip.ts b/src/features/export/storedZip.ts similarity index 100% rename from src/storedZip.ts rename to src/features/export/storedZip.ts diff --git a/src/__tests__/canvas-preview-test.ts b/src/features/rendering/__tests__/canvas-preview-test.ts similarity index 82% rename from src/__tests__/canvas-preview-test.ts rename to src/features/rendering/__tests__/canvas-preview-test.ts index 8b37226..c9dabec 100644 --- a/src/__tests__/canvas-preview-test.ts +++ b/src/features/rendering/__tests__/canvas-preview-test.ts @@ -1,6 +1,6 @@ -import { applyAvatarEyeDefaults, defaultAvatarEyes } from '../avatars' -import { resolveCanvasPreviewExpression } from '../canvasPreview' -import { defaultExpression } from '../presets' +import { applyAvatarEyeDefaults, defaultAvatarEyes } from '@/features/avatar/avatars' +import { resolveCanvasPreviewExpression } from '@/features/rendering/canvasPreview' +import { defaultExpression } from '@/features/avatar/presets' describe('canvas preview expression', () => { it('keeps customized neutral eyes applied while rotating the body preview', () => { diff --git a/src/__tests__/rendered-rotation-gizmo-test.ts b/src/features/rendering/__tests__/rendered-rotation-gizmo-test.ts similarity index 73% rename from src/__tests__/rendered-rotation-gizmo-test.ts rename to src/features/rendering/__tests__/rendered-rotation-gizmo-test.ts index 5ca3274..0f8eb61 100644 --- a/src/__tests__/rendered-rotation-gizmo-test.ts +++ b/src/features/rendering/__tests__/rendered-rotation-gizmo-test.ts @@ -1,5 +1,8 @@ -import { defaultExpression } from '../presets' -import { createRenderedRotationGizmo, paintRenderedRotationGizmo } from '../renderedRotationGizmo' +import { defaultExpression } from '@/features/avatar/presets' +import { + createRenderedRotationGizmo, + paintRenderedRotationGizmo, +} from '@/features/rendering/renderedRotationGizmo' describe('rendered rotation gizmo', () => { it('updates its rings without requiring a React render', () => { diff --git a/src/__tests__/rendered-scene-test.ts b/src/features/rendering/__tests__/rendered-scene-test.ts similarity index 79% rename from src/__tests__/rendered-scene-test.ts rename to src/features/rendering/__tests__/rendered-scene-test.ts index 8bc3525..f4dcdb7 100644 --- a/src/__tests__/rendered-scene-test.ts +++ b/src/features/rendering/__tests__/rendered-scene-test.ts @@ -1,18 +1,18 @@ -import { createBodyNode } from '../body' -import { renderAvatar, poseFromExpression } from '../geometry' -import { defaultExpression } from '../presets' +import { createBodyNode } from '@/features/avatar/body' +import { renderAvatar, poseFromExpression } from '@/features/avatar/geometry' +import { defaultExpression } from '@/features/avatar/presets' import { createRenderedColors, createRenderedScene, findBodyNodePath, paintRenderedColors, paintRenderedScene, -} from '../renderedScene' -import { surfacePresets } from '../surfaces' -import defaultStudioDocument from '../defaultStudioDocument.json' -import type { BodyNode } from '../body' -import type { Expression } from '../geometry' -import type { SurfaceConfig } from '../surfaces' +} from '@/features/rendering/renderedScene' +import { surfacePresets } from '@/features/avatar/surfaces' +import defaultStudioDocument from '@/features/studio/defaultStudioDocument.json' +import type { BodyNode } from '@/features/avatar/body' +import type { Expression } from '@/features/avatar/geometry' +import type { SurfaceConfig } from '@/features/avatar/surfaces' describe('rendered avatar scene', () => { it('keeps layer identity and hit mapping behind the scene seam', () => { diff --git a/src/canvasPreview.ts b/src/features/rendering/canvasPreview.ts similarity index 84% rename from src/canvasPreview.ts rename to src/features/rendering/canvasPreview.ts index ddd213b..43c7ec0 100644 --- a/src/canvasPreview.ts +++ b/src/features/rendering/canvasPreview.ts @@ -1,5 +1,5 @@ -import { applyAvatarEyeDefaults, type AvatarEyeDefaults } from './avatars' -import { type Expression } from './geometry' +import { applyAvatarEyeDefaults, type AvatarEyeDefaults } from '../avatar/avatars' +import { type Expression } from '../avatar/geometry' export type CanvasPreviewTarget = 'head' | 'eyes' diff --git a/src/features/rendering/components/AvatarCanvas.tsx b/src/features/rendering/components/AvatarCanvas.tsx new file mode 100644 index 0000000..1d1f8c8 --- /dev/null +++ b/src/features/rendering/components/AvatarCanvas.tsx @@ -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): readonly [number, number] => { + const rectangle = event.currentTarget.ownerSVGElement!.getBoundingClientRect() + return [ + ((event.clientX - rectangle.left) / rectangle.width) * 86 - 43, + ((event.clientY - rectangle.top) / rectangle.height) * 86 - 43, + ] + } + const unitVector = (from: Point3, to: Point3): readonly [number, number] => { + const x = to[0] - from[0] + const y = to[1] - from[1] + const length = Math.hypot(x, y) || 1 + return [x / length, y / length] + } + const startAxis = (axis: 'x' | 'y' | 'z', event: React.PointerEvent) => { + event.stopPropagation() + onActiveChange(true) + const point = toLocal(event) + const ring = rings[axis] + let closestIndex = 0 + let closestDistance = Number.POSITIVE_INFINITY + ring.slice(0, -1).forEach((ringPoint, index) => { + const distance = Math.hypot(ringPoint[0] - point[0], ringPoint[1] - point[1]) + if (distance < closestDistance) { + closestIndex = index + closestDistance = distance + } + }) + const previous = ring[(closestIndex - 1 + ring.length - 1) % (ring.length - 1)] + const next = ring[(closestIndex + 1) % (ring.length - 1)] + drag.current = { + type: 'axis', + axis, + startPoint: point, + tangent: unitVector(previous, next), + expression, + } + event.currentTarget.setPointerCapture(event.pointerId) + } + const startView = (event: React.PointerEvent) => { + event.stopPropagation() + onActiveChange(true) + const point = toLocal(event) + drag.current = { type: 'view', startAngle: Math.atan2(point[1], point[0]), expression } + event.currentTarget.setPointerCapture(event.pointerId) + } + const move = (event: React.PointerEvent) => { + if (!drag.current) return + const point = toLocal(event) + if (drag.current.type === 'view') { + const currentAngle = Math.atan2(point[1], point[0]) + const delta = Math.atan2( + Math.sin(currentAngle - drag.current.startAngle), + Math.cos(currentAngle - drag.current.startAngle) + ) + onChange(rotateExpressionAroundCamera(drag.current.expression, delta)) + return + } + const signedDistance = + (point[0] - drag.current.startPoint[0]) * drag.current.tangent[0] + + (point[1] - drag.current.startPoint[1]) * drag.current.tangent[1] + onChange( + rotateExpressionAroundAxis(drag.current.expression, drag.current.axis, signedDistance * 1.5) + ) + } + const stop = () => { + drag.current = null + onActiveChange(false) + } + const cancel = () => { + if (drag.current) onChange(drag.current.expression) + stop() + } + useEscapeToCancel(cancel) + return ( +
+ + + startAxis('y', event)} + onPointerMove={move} + onPointerUp={stop} + onPointerCancel={cancel} + /> + startAxis('x', event)} + onPointerMove={move} + onPointerUp={stop} + onPointerCancel={cancel} + /> + startAxis('z', event)} + onPointerMove={move} + onPointerUp={stop} + onPointerCancel={cancel} + /> + + +
+ ) +} + +type TransformAxis = 'x' | 'y' | 'z' + +export function BodyNodeGizmo({ + svgRef, + pose, + node, + onPreview, + onCommit, +}: { + svgRef: React.RefObject + pose: AvatarPose + node: BodyNode + onPreview: (next: BodyNode) => void + onCommit: (next: BodyNode) => void +}) { + const { t } = useStudioLanguage() + const geometry = renderBodyNodeEditor(pose, node) + const [activeControl, setActiveControl] = useState< + { mode: 'translate' | 'rotate'; axis: TransformAxis } | { mode: 'plane' } | undefined + >(undefined) + const drag = useRef< + | { + mode: 'translate' | 'rotate' + axis: TransformAxis + startPoint: readonly [number, number] + direction: readonly [number, number] + scale: number + node: BodyNode + } + | { + mode: 'plane' + startPoint: readonly [number, number] + node: BodyNode + } + | undefined + >(undefined) + const latestNode = useRef(node) + const manipulation = useRef | null>(null) + const previewFrame = useRef(undefined) + const axes: TransformAxis[] = ['x', 'y', 'z'] + const toSvg = (event: React.PointerEvent): readonly [number, number] => { + const rectangle = svgRef.current!.getBoundingClientRect() + return [ + ((event.clientX - rectangle.left) / rectangle.width) * 300 - 150, + ((event.clientY - rectangle.top) / rectangle.height) * 300 - 150, + ] + } + const directionBetween = (from: Point3, to: Point3): readonly [number, number] => { + const x = to[0] - from[0] + const y = to[1] - from[1] + const length = Math.hypot(x, y) || 1 + return [x / length, y / length] + } + const ringPath = (points: Point3[]) => + `M${points.map(point => `${point[0]} ${point[1]}`).join('L')}Z` + const startTranslate = (axis: TransformAxis, event: React.PointerEvent) => { + event.stopPropagation() + const endpoint = geometry.axes[axis] + const length = Math.max( + Math.hypot(endpoint[0] - geometry.center[0], endpoint[1] - geometry.center[1]), + 1 + ) + drag.current = { + mode: 'translate', + axis, + startPoint: toSvg(event), + direction: directionBetween(geometry.center, endpoint), + scale: 34 / length, + node, + } + setActiveControl({ mode: 'translate', axis }) + latestNode.current = node + manipulation.current = beginManipulation(node) + event.currentTarget.setPointerCapture(event.pointerId) + } + const startRotate = (axis: TransformAxis, event: React.PointerEvent) => { + event.stopPropagation() + const point = toSvg(event) + const ring = geometry.rings[axis] + let closestIndex = 0 + let closestDistance = Infinity + ring.slice(0, -1).forEach((ringPoint, index) => { + const distance = Math.hypot(ringPoint[0] - point[0], ringPoint[1] - point[1]) + if (distance < closestDistance) { + closestIndex = index + closestDistance = distance + } + }) + const previous = ring[(closestIndex - 1 + ring.length - 1) % (ring.length - 1)] + const next = ring[(closestIndex + 1) % (ring.length - 1)] + const radius = Math.max( + Math.hypot( + ring[closestIndex][0] - geometry.center[0], + ring[closestIndex][1] - geometry.center[1] + ), + 8 + ) + drag.current = { + mode: 'rotate', + axis, + startPoint: point, + direction: directionBetween(previous, next), + scale: 180 / Math.PI / radius, + node, + } + setActiveControl({ mode: 'rotate', axis }) + latestNode.current = node + manipulation.current = beginManipulation(node) + event.currentTarget.setPointerCapture(event.pointerId) + } + const startPlaneTranslate = (event: React.PointerEvent) => { + event.stopPropagation() + drag.current = { mode: 'plane', startPoint: toSvg(event), node } + setActiveControl({ mode: 'plane' }) + latestNode.current = node + manipulation.current = beginManipulation(node) + event.currentTarget.setPointerCapture(event.pointerId) + } + const move = (event: React.PointerEvent) => { + if (!drag.current) return + const interaction = drag.current + const point = toSvg(event) + const deltaX = point[0] - interaction.startPoint[0] + const deltaY = point[1] - interaction.startPoint[1] + const next = (() => { + if (interaction.mode === 'plane') { + return translateBodyNodeInCameraPlane(interaction.node, pose, deltaX, deltaY) + } + const delta = + (deltaX * interaction.direction[0] + deltaY * interaction.direction[1]) * interaction.scale + return interaction.mode === 'translate' + ? translateBodyNodeAlongLocalAxis(interaction.node, interaction.axis, delta) + : rotateBodyNodeAroundLocalAxis(interaction.node, interaction.axis, delta) + })() + latestNode.current = next + if (manipulation.current) { + previewManipulation(manipulation.current, next) + } + if (previewFrame.current !== undefined) return + previewFrame.current = requestAnimationFrame(() => { + previewFrame.current = undefined + onPreview(latestNode.current) + }) + } + const stop = () => { + if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current) + previewFrame.current = undefined + if (manipulation.current) { + finishManipulation(manipulation.current, 'commit', { preview: onPreview, commit: onCommit }) + } + manipulation.current = null + drag.current = undefined + setActiveControl(undefined) + } + const cancel = () => { + if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current) + previewFrame.current = undefined + if (manipulation.current) { + finishManipulation(manipulation.current, 'cancel', { preview: onPreview, commit: onCommit }) + } + manipulation.current = null + drag.current = undefined + setActiveControl(undefined) + } + useEscapeToCancel(cancel) + const resetTransform = (event: React.MouseEvent) => { + event.stopPropagation() + const resetNode: BodyNode = { ...node, position: [0, 0, 0], rotation: [0, 0, 0] } + latestNode.current = resetNode + onCommit(resetNode) + } + + useEffect( + () => () => { + if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current) + }, + [] + ) + + return ( + + {axes.map(axis => ( + + startRotate(axis, event)} + onPointerMove={move} + onPointerUp={stop} + onPointerCancel={cancel} + /> + + + ))} + {axes.map(axis => ( + + startTranslate(axis, event)} + onPointerMove={move} + onPointerUp={stop} + onPointerCancel={cancel} + /> + + + + {axis.toUpperCase()} + + + ))} + + + { + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + const resetNode: BodyNode = { ...node, position: [0, 0, 0], rotation: [0, 0, 0] } + latestNode.current = resetNode + onCommit(resetNode) + }} + > + + ↺ + + + ) +} + +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 } | null + onManipulationStart: () => Expression +}) { + const { t } = useStudioLanguage() + const { + wirePaths, + backPaths, + frontPaths, + backNodeIds, + frontNodeIds, + headPath, + leftPath, + rightPath, + leftOpacity, + rightOpacity, + offsetX, + offsetY, + } = scene + const svgRef = useRef(null) + const [activeDragType, setActiveDragType] = useState< + 'arcball' | 'width' | 'height' | 'size' | 'spacing' | 'rotate' | null + >(null) + const drag = useRef< + | { + type: 'arcball' + startPoint: readonly [number, number] + expression: Expression + } + | { + type: 'width' | 'height' | 'size' | 'spacing' | 'rotate' + side: -1 | 1 + startPoint: readonly [number, number] + expression: Expression + center: Point3 + widthAxis: readonly [number, number] + heightAxis: readonly [number, number] + spacingAxis: readonly [number, number] + startPointerAngle: number + startDistance: number + } + | null + >(null) + const canvasManipulation = useRef | null>(null) + const editor = + selectedSide === null + ? null + : renderEyeEditor(poseWithAvatarEyes(expression, avatarEyes), surface, selectedSide) + const selectedBodyPath = (() => { + if (!bodyEditing || !selectedBodyNodeId) return null + return findBodyNodePath(scene, selectedBodyNodeId) + })() + + const toSvg = (event: React.PointerEvent): readonly [number, number] => { + const rectangle = svgRef.current!.getBoundingClientRect() + return [ + ((event.clientX - rectangle.left) / rectangle.width) * 300 - 150, + ((event.clientY - rectangle.top) / rectangle.height) * 300 - 150, + ] + } + const unitVector = (from: Point3, to: Point3): readonly [number, number] => { + const x = to[0] - from[0] + const y = to[1] - from[1] + const length = Math.hypot(x, y) || 1 + return [x / length, y / length] + } + const startDrag = (event: React.PointerEvent) => { + const session = beginManipulationFromRenderedValue(onManipulationStart) + const renderedExpression = session.initial + onBodyNodeSelect('primary') + onHighlightChange('head') + drag.current = { + type: 'arcball', + startPoint: toSvg(event), + expression: renderedExpression, + } + canvasManipulation.current = session + setActiveDragType('arcball') + svgRef.current!.setPointerCapture(event.pointerId) + } + const selectBodyPath = ( + event: React.PointerEvent, + nodeId: string | null | undefined + ) => { + if (!nodeId || !bodyEditing) { + startDrag(event) + return + } + event.stopPropagation() + onBodyNodeSelect(nodeId) + } + const selectEye = (side: -1 | 1, event: React.PointerEvent) => { + event.stopPropagation() + onBodyNodeSelect(null) + onEyeSelect(side) + } + const startHandle = ( + type: 'width' | 'height' | 'size' | 'spacing' | 'rotate', + event: React.PointerEvent + ) => { + event.stopPropagation() + if (selectedSide === null || !editor) return + const session = beginManipulationFromRenderedValue(onManipulationStart) + const renderedExpression = session.initial + onHighlightChange(selectedSide < 0 ? 'left' : 'right') + const point = toSvg(event) + const editableExpression = bodyEditing + ? applyAvatarEyeDefaults(renderedExpression, avatarEyes) + : renderedExpression + const livePose = poseWithAvatarEyes(renderedExpression, avatarEyes) + const liveEditor = renderEyeEditor(livePose, surface, selectedSide) + const leftEditor = renderEyeEditor(livePose, surface, -1) + const rightEditor = renderEyeEditor(livePose, surface, 1) + drag.current = { + type, + side: selectedSide, + startPoint: point, + expression: editableExpression, + center: liveEditor.center, + widthAxis: unitVector(liveEditor.center, liveEditor.widthHandle), + heightAxis: unitVector(liveEditor.center, liveEditor.heightHandle), + spacingAxis: unitVector(leftEditor.center, rightEditor.center), + startPointerAngle: Math.atan2( + point[1] - liveEditor.center[1], + point[0] - liveEditor.center[0] + ), + startDistance: Math.max( + Math.hypot(point[0] - liveEditor.center[0], point[1] - liveEditor.center[1]), + 1 + ), + } + canvasManipulation.current = beginManipulation(editableExpression) + setActiveDragType(type) + svgRef.current!.setPointerCapture(event.pointerId) + } + const move = (event: React.PointerEvent) => { + if (!drag.current) return + const point = toSvg(event) + if (drag.current.type === 'arcball') { + const next = rotateExpressionWithArcball( + drag.current.expression, + drag.current.startPoint, + point + ) + if (canvasManipulation.current) { + previewManipulation(canvasManipulation.current, next, value => onPreview(value, 'head')) + } + return + } + const interaction = drag.current + const suffix = interaction.side < 0 ? 'Left' : 'Right' + const deltaX = point[0] - interaction.startPoint[0] + const deltaY = point[1] - interaction.startPoint[1] + const along = (axis: readonly [number, number]) => deltaX * axis[0] + deltaY * axis[1] + let next = { ...interaction.expression } + const side = interaction.side < 0 ? 'Left' : 'Right' + if (interaction.type === 'width') { + const value = bounded( + interaction.expression[`width${suffix}`] + along(interaction.widthAxis) * 2, + 10, + 100 + ) + next = updateEyeDimension(interaction.expression, side, 'width', value, linked.width) + } else if (interaction.type === 'height') { + const value = bounded( + interaction.expression[`height${suffix}`] + along(interaction.heightAxis) * 2, + 10, + 100 + ) + next = updateEyeDimension(interaction.expression, side, 'height', value, linked.height) + } else if (interaction.type === 'size') { + const distance = Math.hypot( + point[0] - interaction.center[0], + point[1] - interaction.center[1] + ) + const factor = distance / interaction.startDistance + const targetSize = + Math.max( + interaction.expression[`width${suffix}`], + interaction.expression[`height${suffix}`] + ) * factor + next = scaleEye(interaction.expression, side, targetSize, linked.size) + } else if (interaction.type === 'spacing') { + const startSpacing = interaction.expression.spacing + const spacing = bounded(startSpacing + along(interaction.spacingAxis), 0, 150) + next.spacing = spacing + } else { + const currentAngle = Math.atan2( + point[1] - interaction.center[1], + point[0] - interaction.center[0] + ) + const deltaAngle = Math.atan2( + Math.sin(currentAngle - interaction.startPointerAngle), + Math.cos(currentAngle - interaction.startPointerAngle) + ) + next[interaction.side < 0 ? 'leftAngle' : 'rightAngle'] = + interaction.expression[interaction.side < 0 ? 'leftAngle' : 'rightAngle'] + + (deltaAngle * 180) / Math.PI + } + if (canvasManipulation.current) { + previewManipulation(canvasManipulation.current, next, value => onPreview(value, 'eyes')) + } + } + const commitDrag = () => { + const interaction = drag.current + const session = canvasManipulation.current + if (interaction && session) { + finishManipulation(session, 'commit', { + preview: value => onPreview(value, interaction.type === 'arcball' ? 'head' : 'eyes'), + commit: interaction.type === 'arcball' ? onChange : (onEyeChange ?? onChange), + }) + } + canvasManipulation.current = null + drag.current = null + setActiveDragType(null) + onHighlightChange(null) + } + const cancelDrag = () => { + const interaction = drag.current + const session = canvasManipulation.current + if (interaction && session) { + finishManipulation(session, 'cancel', { + preview: value => onPreview(value, interaction.type === 'arcball' ? 'head' : 'eyes'), + commit: onChange, + }) + } + canvasManipulation.current = null + drag.current = null + setActiveDragType(null) + onHighlightChange(null) + } + useEscapeToCancel(cancelDrag) + return ( +
+ {playback && ( + + + + )} + + + + + + + + {backPaths.map((pathValue, index) => ( + selectBodyPath(event, backNodeIds.current[index])} + /> + ))} + { + onBodyNodeSelect('primary') + startDrag(event) + }} + /> + + {(showWire || highlight === 'head') && + wirePaths.map((pathValue, index) => ( + + ))} + selectEye(-1, event)} + /> + selectEye(1, event)} + /> + + {frontPaths.map((pathValue, index) => ( + selectBodyPath(event, frontNodeIds.current[index])} + /> + ))} + + {selectedBodyPath && ( + + )} + {bodyEditing && selectedBodyNode && ( + + )} + {editor?.visible && ( + + {activeDragType !== null && activeDragType !== 'arcball' && ( + + )} + + + + + + + + + + + )} + + onHighlightChange(active ? 'head' : null)} + onReset={() => onReset({ ...expression, headX: 0, headY: 0, headZ: 0 })} + /> +
+ X Y Z +
+
+ ) +} + +type EyeHandle = 'width' | 'height' | 'size' | 'spacing' | 'rotate' +type HandleStart = (type: EyeHandle, event: React.PointerEvent) => void + +export function EditorCircle({ + point, + label, + type, + onStart, +}: { + point: Point3 + label: string + type: EyeHandle + onStart: HandleStart +}) { + return ( + onStart(type, event)} + > + + + {label} + + + ) +} + +export function EditorSquare({ + point, + label, + type, + onStart, +}: { + point: Point3 + label: string + type: EyeHandle + onStart: HandleStart +}) { + return ( + onStart(type, event)} + > + + + {label} + + + ) +} diff --git a/src/renderedRotationGizmo.ts b/src/features/rendering/renderedRotationGizmo.ts similarity index 97% rename from src/renderedRotationGizmo.ts rename to src/features/rendering/renderedRotationGizmo.ts index ab5cf32..5179f18 100644 --- a/src/renderedRotationGizmo.ts +++ b/src/features/rendering/renderedRotationGizmo.ts @@ -1,6 +1,6 @@ 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 = { xPath: MotionValue diff --git a/src/renderedScene.ts b/src/features/rendering/renderedScene.ts similarity index 95% rename from src/renderedScene.ts rename to src/features/rendering/renderedScene.ts index 952b929..39faa5c 100644 --- a/src/renderedScene.ts +++ b/src/features/rendering/renderedScene.ts @@ -1,8 +1,8 @@ import { motionValue, type MotionValue } from 'motion' -import type { AvatarColors } from './avatars' -import { MAX_BODY_NODES } from './body' -import type { AvatarGeometry } from './geometry' +import type { AvatarColors } from '../avatar/avatars' +import { MAX_BODY_NODES } from '../avatar/body' +import type { AvatarGeometry } from '../avatar/geometry' export type RenderedScene = { headPath: MotionValue diff --git a/src/__tests__/studio-document-test.ts b/src/features/studio/__tests__/studio-document-test.ts similarity index 96% rename from src/__tests__/studio-document-test.ts rename to src/features/studio/__tests__/studio-document-test.ts index 9385b1f..ff2e146 100644 --- a/src/__tests__/studio-document-test.ts +++ b/src/features/studio/__tests__/studio-document-test.ts @@ -1,13 +1,13 @@ -import { createAvatar } from '../avatars' -import { createInitialSequences } from '../sequences' -import { initialExpressions } from '../presets' +import { createAvatar } from '@/features/avatar/avatars' +import { createInitialSequences } from '@/features/animation/sequences' +import { initialExpressions } from '@/features/avatar/presets' import { createStudioDocumentStore, loadStudioDocument, parseImportedStudioDocument, serializeStudioDocument, type StudioDocument, -} from '../studioDocument' +} from '@/features/studio/studioDocument' const documentFixture = (): StudioDocument => { const avatar = createAvatar('Strobi') diff --git a/src/features/studio/components/StudioDialogs.tsx b/src/features/studio/components/StudioDialogs.tsx new file mode 100644 index 0000000..caa6ff6 --- /dev/null +++ b/src/features/studio/components/StudioDialogs.tsx @@ -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 ( + <> + + + + {t(`Supprimer ${activeAvatar.name} ?`)} + + {t( + 'Le corps, les expressions et les animations propres à cet avatar seront définitivement supprimés. La bibliothèque de base sera conservée.' + )} + + + + {t('Annuler')} + {t('Supprimer')} + + + + + + + {t('Supprimer cette expression ?')} + + {t( + 'Cette action retirera définitivement le preset de la bibliothèque de cet avatar.' + )} + + {animationsAffectedByExpressionDeletion.length > 0 && ( +
+

+ {t('Cette expression sera aussi retirée des animations suivantes :')} +

+
+ {animationsAffectedByExpressionDeletion.map(sequence => ( + + {sequence.name} + + ))} +
+

+ {t( + 'Si une animation ne contient que cette expression, l’expression de repli lui sera assignée pour qu’elle reste jouable.' + )} +

+
+ )} +
+ + {t('Annuler')} + {t('Supprimer')} + +
+
+ + + + {t('Supprimer cette animation ?')} + + {t('Cette action supprimera définitivement cette animation.')} + + + + {t('Annuler')} + {t('Supprimer')} + + + + { + if (!open) setPendingProjectImport(null) + }} + > + + + {t('Importer ce projet ?')} + + {t( + 'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.' + )}{' '} + {pendingProjectImport?.fileName} + + + + {t('Annuler')} + + {t('Importer')} + + + + + + ) +} diff --git a/src/features/studio/components/StudioInspector.tsx b/src/features/studio/components/StudioInspector.tsx new file mode 100644 index 0000000..5cbb110 --- /dev/null +++ b/src/features/studio/components/StudioInspector.tsx @@ -0,0 +1,1977 @@ +import { + ArrowLeft, + ChevronDown, + ChevronUp, + Copy, + Download, + FileCode2, + Pencil, + Plus, + RotateCcw, + Trash2, + Upload, +} from 'lucide-react' +import { motion } from 'motion/react' +import { type CSSProperties } from 'react' + +import { Accordion } from '@/components/ui/accordion' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + 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 { Separator } from '@/components/ui/separator' +import { Switch } from '@/components/ui/switch' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' + +import { + ControlSection, + ExportSection, + InspectorCard, + PanelTitle, + SnapshotPreview, + StatePlayer, +} from '@/app/components/common' +import { ColorField, LinkButton, NumericField } from '@/app/components/controls' +import { + formatSeconds, + scaleSurface, + type Mode, + type Side, + type SnapshotFormat, +} from '@/app/studio-utils' +import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace' +import { findExpressionIndex, groupSequences } from '@/features/animation/sequences' +import { defaultAvatarEyes } from '@/features/avatar/avatars' +import { bodyPrimitiveTypes, MAX_BODY_NODES } from '@/features/avatar/body' +import { + ExpressionCard, + ExpressionPreview, + ExpressionWorkspace, + SurfaceThumbnail, +} from '@/features/avatar/components/ExpressionWorkspace' +import { defaultExpression } from '@/features/avatar/presets' +import { surfaceLabels, surfacePresets } from '@/features/avatar/surfaces' +import { type SnapshotBackground } from '@/features/export/snapshotExporter' +import type { StudioController } from '@/features/studio/useStudioController' + +export function StudioInspector({ controller }: { controller: StudioController }) { + const { + activateAvatar, + activeAvatar, + activeAvatarEyes, + activeAvatarId, + activeExpression, + activeSequence, + activeSequenceLabel, + activeState, + addBodyNode, + avatarDragOrigin, + avatarDragPreview, + avatars, + avatarsRef, + blink, + bodyEditing, + bodyNodes, + cancelAvatarEditing, + cancelAvatarMove, + cancelExpressionEditing, + cancelExpressionMove, + cancelSequenceEditing, + cancelStateMove, + commitAvatarMove, + commitExpressionMove, + commitStateMove, + createNewAvatar, + deleteSelectedBodyNode, + downloadAvatarExport, + downloadStudioProject, + draggedAvatarId, + draggedExpressionId, + draggedStateId, + draggingAvatarId, + draggingExpressionId, + draggingStateId, + duplicateAvatar, + duplicateExpression, + duplicateSelectedBodyNode, + duplicateSequenceEditing, + duplicateState, + editing, + editorPageOpen, + exportAnimationIdSet, + exportFormat, + expression, + expressionById, + expressionDragOrigin, + expressionDragPreview, + expressions, + focusAvatarName, + language, + launchSequence, + linked, + mode, + openExpressionEditor, + openSequenceEditor, + pauseState, + playbackStatus, + playbackVisual, + prepareStudioProjectImport, + previewAvatarMove, + previewExpressionDraft, + previewExpressionMove, + previewStateMove, + projectImportError, + projectImportRef, + reduceMotion, + renameActiveAvatar, + renderedColors, + renderedScene, + saveAvatarEditing, + saveEditing, + saveSequenceEditing, + selectBodyNode, + selectedBodyNode, + selectedBodyNodeId, + selectedExportAnimations, + selectedSequenceStepId, + selectedState, + sequenceEditing, + sequences, + setDeleteAvatarOpen, + setDeleteExpressionOpen, + setDeleteSequenceOpen, + setDraggingAvatarId, + setDraggingExpressionId, + setDraggingStateId, + setExportAnimationIds, + setExportFormat, + setFocusAvatarName, + setLinked, + setMode, + setSelectedSequenceStepId, + setSequenceEditing, + setSnapshotBackground, + setSnapshotColorFrom, + setSnapshotColorTo, + setSnapshotFormat, + setSnapshotSize, + setSpringSpeed, + setStatePlayerExpanded, + showWire, + snapshotBackground, + snapshotColorFrom, + snapshotColorTo, + snapshotFormat, + snapshotSize, + springSpeed, + springSpeedRef, + stateDragOrigin, + stateDragPreview, + statePlayerExpanded, + statePlaying, + stopState, + surface, + t, + toggleExportAnimation, + toggleStatePlayback, + transitionToExpression, + updateAvatarColors, + updateAvatarEyeDimension, + updateAvatarEyePosition, + updateAvatarEyeSize, + updateAvatarEyes, + updateDimension, + updateHighlight, + updateImmediate, + updateNodeVector, + updateSelectedBodyNode, + updateSize, + updateSpacing, + updateSurface, + updateWireVisibility, + workspaceBackButtonRef, + } = controller + return ( +
+ {sequenceEditing && !editing && ( + + + setSequenceEditing(current => (current ? { ...current, draft } : current)) + } + onPreviewStep={step => { + const expressionIndex = findExpressionIndex(expressions, step.expressionId) + const preset = expressions[expressionIndex] + if (preset) transitionToExpression(preset, expressionIndex, step) + }} + onEditExpression={openExpressionEditor} + onPlay={() => launchSequence(sequenceEditing.draft, false, false)} + onPause={() => pauseState(false)} + onStop={() => stopState(false)} + playing={statePlaying} + active={activeState === sequenceEditing.draft.id} + onCancel={cancelSequenceEditing} + onSave={saveSequenceEditing} + onDuplicate={duplicateSequenceEditing} + onDelete={() => setDeleteSequenceOpen(true)} + /> + + )} + {editing && ( + + duplicateExpression(editing.index, editing.draft, true)} + onDelete={() => setDeleteExpressionOpen(true)} + /> + + )} + {!sequenceEditing && + !editing && + (bodyEditing ? ( +
+ +
+

{t('Construction du corps')}

+ renameActiveAvatar(event.currentTarget.value)} + onFocus={event => { + if (focusAvatarName) event.currentTarget.select() + }} + onBlur={event => { + if (!event.currentTarget.value.trim()) renameActiveAvatar('Unknown') + setFocusAvatarName(false) + }} + /> +

{t('Choisis la forme principale puis assemble les primitives autour d’elle.')}

+
+
+ ) : ( + <> +
+
+ Avatars + {t('Double-clic pour modifier')} +
+
+ {avatars.map(avatar => ( + + + { + avatarDragOrigin.current = avatarsRef.current + avatarDragPreview.current = avatarsRef.current + draggedAvatarId.current = avatar.id + setDraggingAvatarId(avatar.id) + event.dataTransfer.effectAllowed = 'move' + }} + onDragEnter={() => previewAvatarMove(avatar.id)} + onDragOver={event => { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + }} + onDrop={event => { + event.preventDefault() + commitAvatarMove(avatar.id) + }} + onDragEnd={cancelAvatarMove} + onClick={() => activateAvatar(avatar.id, false, true)} + onDoubleClick={() => { + setFocusAvatarName(false) + activateAvatar(avatar.id, true) + }} + > + + {avatar.name} + + } + /> + + { + setFocusAvatarName(false) + activateAvatar(avatar.id, true) + }} + > + {t('Modifier')} + + duplicateAvatar(avatar)}> + {t('Dupliquer')} + + + { + activateAvatar(avatar.id, false, true) + setDeleteAvatarOpen(true) + }} + > + {t('Supprimer')} + + + + + ))} + +
+
+ setMode(value as Mode)}> + + {t('Pose')} + {t('Expressions')} + {t('Animations')} + {t('Exporter')} + + + + ))} + + {!editing && mode === 'manual' && ( +
+ {bodyEditing && ( + <> + + + +
+ + {bodyNodes.map(node => ( + + ))} +
+
+ + {t('Ajouter une forme')} · {bodyNodes.length}/{MAX_BODY_NODES} + +
+ {bodyPrimitiveTypes.map(type => ( + + ))} +
+
+ {selectedBodyNode && ( +
+
+ {selectedBodyNode.name} +
+ + +
+
+

+ {t('Gizmo local')} + {t( + 'Glisse un axe pour déplacer la forme, ou un anneau pour la faire tourner.' + )} +

+
+ + updateSelectedBodyNode(node => ({ + ...node, + surface: scaleSurface(node.surface, size, { + width: 10, + height: 10, + depth: 10, + }), + })) + } + /> + {(['width', 'height', 'depth'] as const).map(dimension => ( + + updateSelectedBodyNode(node => ({ + ...node, + surface: { ...node.surface, [dimension]: value }, + })) + } + /> + ))} + {(selectedBodyNode.surface.type === 'cube' || + selectedBodyNode.surface.type === 'diamond' || + selectedBodyNode.surface.type === 'cylinder') && ( + + updateSelectedBodyNode(node => ({ + ...node, + surface: { ...node.surface, roundness }, + })) + } + /> + )} + {(selectedBodyNode.surface.type === 'cylinder' || + selectedBodyNode.surface.type === 'cone') && ( + + updateSelectedBodyNode(node => ({ + ...node, + surface: { ...node.surface, morphRoundness }, + })) + } + /> + )} + {selectedBodyNode.surface.type === 'cone' && ( + <> + + updateSelectedBodyNode(node => ({ + ...node, + surface: { ...node.surface, tipRoundness }, + })) + } + /> + + updateSelectedBodyNode(node => ({ + ...node, + surface: { ...node.surface, baseRoundness }, + })) + } + /> + + )} +
+
+
+

{t('Position locale')}

+ {(['X', 'Y', 'Z'] as const).map((axis, index) => ( + + updateNodeVector('position', index as 0 | 1 | 2, value) + } + /> + ))} +
+
+

{t('Rotation locale')}

+ {(['X', 'Y', 'Z'] as const).map((axis, index) => ( + + updateNodeVector('rotation', index as 0 | 1 | 2, value) + } + /> + ))} +
+
+
+ )} +
+ + +
+ {bodyPrimitiveTypes.map(type => { + const previewSurface = type === surface.type ? surface : surfacePresets[type] + return ( + + ) + })} +
+
+ + updateSurface( + scaleSurface(surface, size, { width: 120, height: 120, depth: 100 }) + ) + } + /> + updateSurface({ ...surface, width })} + /> + updateSurface({ ...surface, height })} + /> + updateSurface({ ...surface, depth })} + /> + {(surface.type === 'cube' || surface.type === 'diamond') && ( + updateHighlight(active ? 'head' : null)} + onChange={roundness => updateSurface({ ...surface, roundness })} + /> + )} + {surface.type === 'cylinder' && ( + updateHighlight(active ? 'head' : null)} + onChange={roundness => updateSurface({ ...surface, roundness })} + /> + )} + {(surface.type === 'cylinder' || surface.type === 'cone') && ( + updateHighlight(active ? 'head' : null)} + onChange={morphRoundness => updateSurface({ ...surface, morphRoundness })} + /> + )} + {surface.type === 'cone' && ( + <> + updateHighlight(active ? 'head' : null)} + onChange={tipRoundness => updateSurface({ ...surface, tipRoundness })} + /> + updateHighlight(active ? 'head' : null)} + onChange={baseRoundness => updateSurface({ ...surface, baseRoundness })} + /> + + )} +
+
+ + + updateAvatarColors({ body })} + /> + +
+ +

+ {t( + 'Définis l’identité du regard de cet avatar. Les poses s’ajoutent ensuite à cette base.' + )} +

+ {(['width', 'height', 'size'] as const).map(dimension => ( + +
+

+ {t( + { + width: 'Largeur', + height: 'Hauteur', + size: 'Taille proportionnelle', + }[dimension] + )} +

+ + setLinked(current => ({ + ...current, + [dimension]: !current[dimension], + })) + } + /> +
+
+ {(['Left', 'Right'] as Side[]).map(side => { + const width = activeAvatarEyes[`width${side}`] + const height = activeAvatarEyes[`height${side}`] + const value = + dimension === 'width' + ? width + : dimension === 'height' + ? height + : Math.max(width, height) + return ( + + updateHighlight( + active + ? linked[dimension] + ? 'both' + : side === 'Left' + ? 'left' + : 'right' + : null + ) + } + onChange={next => + dimension === 'size' + ? updateAvatarEyeSize(side, next) + : updateAvatarEyeDimension(side, dimension, next) + } + /> + ) + })} +
+
+ ))} + +
+
+

{t('Position et espacement')}

+

+ {t('Coordonnées propres à l’avatar, indépendantes des poses.')} +

+
+ + setLinked(current => ({ ...current, position: !current.position })) + } + /> +
+
+ {(['Left', 'Right'] as Side[]).map(side => ( +
+

{t(side === 'Left' ? 'Œil gauche' : 'Œil droit')}

+ + updateHighlight( + active + ? linked.position + ? 'both' + : side === 'Left' + ? 'left' + : 'right' + : null + ) + } + onChange={value => updateAvatarEyePosition(side, 'X', value)} + /> + + updateHighlight( + active + ? linked.position + ? 'both' + : side === 'Left' + ? 'left' + : 'right' + : null + ) + } + onChange={value => updateAvatarEyePosition(side, 'Y', value)} + /> +
+ ))} +
+
+ updateHighlight(active ? 'both' : null)} + onChange={spacing => updateAvatarEyes({ spacing })} + /> +
+
+ +
+ + + setLinked(current => ({ ...current, rotation: !current.rotation })) + } + /> +
+
+ + updateHighlight(active ? (linked.rotation ? 'both' : 'left') : null) + } + onChange={leftAngle => + updateAvatarEyes({ + leftAngle, + ...(linked.rotation ? { rightAngle: -leftAngle } : {}), + }) + } + /> + + updateHighlight(active ? (linked.rotation ? 'both' : 'right') : null) + } + onChange={rightAngle => + updateAvatarEyes({ + rightAngle, + ...(linked.rotation ? { leftAngle: -rightAngle } : {}), + }) + } + /> +
+
+ + + updateAvatarColors({ eyes })} + /> + +
+ + )} + {!bodyEditing && ( + <> + + + + updateImmediate({ ...expression, bodyColor })} + /> + {expression.bodyColor && ( + + )} + + + + updateHighlight(active ? 'head' : null)} + onChange={value => updateImmediate({ ...expression, headX: value })} + /> + updateHighlight(active ? 'head' : null)} + onChange={value => updateImmediate({ ...expression, headY: value })} + /> + updateHighlight(active ? 'head' : null)} + onChange={value => updateImmediate({ ...expression, headZ: value })} + /> + + + + + + updateImmediate({ ...expression, eyeColor })} + /> + {expression.eyeColor && ( + + )} + + {(['width', 'height', 'size'] as const).map(dimension => ( + +
+

+ {t( + { + width: 'Largeur', + height: 'Hauteur', + size: 'Taille proportionnelle', + }[dimension] + )} +

+ + setLinked(current => ({ + ...current, + [dimension]: !current[dimension], + })) + } + /> +
+
+ {(['Left', 'Right'] as Side[]).map(side => { + const width = expression[`width${side}`] + const height = expression[`height${side}`] + const value = + dimension === 'width' + ? width + : dimension === 'height' + ? height + : Math.max(width, height) + return ( + + updateHighlight( + active + ? linked[dimension] + ? 'both' + : side === 'Left' + ? 'left' + : 'right' + : null + ) + } + onChange={next => + dimension === 'size' + ? updateSize(side, next) + : updateDimension(side, dimension, next) + } + /> + ) + })} +
+
+ ))} + + +
+
+

{t('Œil gauche')}

+ updateHighlight(active ? 'left' : null)} + onChange={value => updateImmediate({ ...expression, positionXLeft: value })} + /> + updateHighlight(active ? 'left' : null)} + onChange={value => updateImmediate({ ...expression, positionYLeft: value })} + /> +
+
+

{t('Œil droit')}

+ updateHighlight(active ? 'right' : null)} + onChange={value => + updateImmediate({ ...expression, positionXRight: value }) + } + /> + updateHighlight(active ? 'right' : null)} + onChange={value => + updateImmediate({ ...expression, positionYRight: value }) + } + /> +
+
+
+ updateHighlight(active ? 'both' : null)} + onChange={updateSpacing} + /> +
+
+ +
+ + + setLinked(current => ({ ...current, rotation: !current.rotation })) + } + /> +
+
+ + updateHighlight(active ? (linked.rotation ? 'both' : 'left') : null) + } + onChange={value => + updateImmediate({ + ...expression, + leftAngle: value, + ...(linked.rotation ? { rightAngle: -value } : {}), + }) + } + /> + + updateHighlight(active ? (linked.rotation ? 'both' : 'right') : null) + } + onChange={value => + updateImmediate({ + ...expression, + rightAngle: value, + ...(linked.rotation ? { leftAngle: -value } : {}), + }) + } + /> +
+
+
+ + + + updateImmediate({ ...expression, perspective: value })} + /> +
+ {t('Afficher le maillage')} + +
+ +
+
+ + )} +
+ )} + + {!sequenceEditing && !editing && bodyEditing && ( +
+
+ + +
+ +
+ )} + + {!sequenceEditing && !editing && !bodyEditing && mode === 'expressions' && ( +
+ +
+
+

{expressions.length} presets

+

{t('Expressions')}

+
+ {t('Double-clic pour modifier')} +
+
+ {expressions.map((preset, index) => ( + + transitionToExpression(preset, index)} + onEdit={() => openExpressionEditor(index, preset)} + onDuplicate={() => duplicateExpression(index, preset)} + onDelete={() => { + openExpressionEditor(index, preset) + setDeleteExpressionOpen(true) + }} + draggable + onDragStart={event => { + expressionDragOrigin.current = expressions + expressionDragPreview.current = expressions + draggedExpressionId.current = preset.id + setDraggingExpressionId(preset.id) + event.dataTransfer.effectAllowed = 'move' + }} + onDragEnter={() => previewExpressionMove(preset.id)} + onDragOver={event => { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + }} + onDrop={event => { + event.preventDefault() + commitExpressionMove(preset.id) + }} + onDragEnd={cancelExpressionMove} + /> + + ))} + +
+
+ + + { + springSpeedRef.current = value + setSpringSpeed(value) + }} + /> +
+ + +
+
+
+ )} + + {!sequenceEditing && !editing && !bodyEditing && mode === 'states' && ( +
+ +
+
+

+ {sequences.length} {t('animations')} +

+

{t('Animations')}

+
+
+
+ {groupSequences(sequences).map(group => ( +
event.preventDefault()} + onDrop={event => { + event.preventDefault() + commitStateMove(null, group.name) + }} + > + + {group.sequences.every(sequence => sequence.builtIn) + ? t(group.name) + : group.name} + +
+ {group.sequences.map(sequence => { + const firstStep = sequence.steps[0] + const firstExpression = firstStep + ? expressionById.get(firstStep.expressionId) + : undefined + const card = ( + + ) + return ( + + + + + openSequenceEditor(sequence)}> + + {t('Modifier')} + + duplicateState(sequence)}> + + {t('Dupliquer')} + + + { + openSequenceEditor(sequence) + setDeleteSequenceOpen(true) + }} + > + + {t('Supprimer')} + + + + + ) + })} +
+
+ ))} +
+ +
+
+
+
+ )} + + {!sequenceEditing && !editing && !bodyEditing && mode === 'export' && ( + + + +
+ +
+ {t('Avatar sélectionné')} + {activeAvatar.name} +
+
+
+ + + +
+ + +
+
+ + +
+
+

+ {selectedExportAnimations.length}/{sequences.length} {t('sélectionnées')} +

+

{t('Animations à exporter')}

+
+ +
+
+ {sequences.map(animation => { + const firstStep = animation.steps[0] + const firstExpression = firstStep + ? expressionById.get(firstStep.expressionId) + : undefined + return ( + + ) + })} +
+
+ + +
+ + + + +
+ {t('Aperçu du mode photo')} + {activeAvatar.name} + + {snapshotSize} × {snapshotSize} px · {snapshotFormat.toUpperCase()} + +
+
+ + + + + {t('Style')} + + + {snapshotBackground !== 'transparent' && ( +
+ + {(snapshotBackground === 'linear' || snapshotBackground === 'radial') && ( + + )} +
+ )} +
+ + + +
+ {t('Format d’export')} + {t('Choisis le type de fichier généré par le mode photo.')} +
+ +
+ + +
+ {t('Définition')} + {t('Dimensions du fichier exporté.')} +
+ +
+
+
+ + +
+ + + { + prepareStudioProjectImport(event.currentTarget.files?.[0]) + event.currentTarget.value = '' + }} + /> +
+ {projectImportError && ( +

+ {projectImportError} +

+ )} +
+
+ )} + {activeSequence && !editorPageOpen && ( + + + {statePlayerExpanded && ( + +
+
+

{t('Détails de l’animation')}

+

{activeSequenceLabel}

+

+ {activeSequence.builtIn + ? t(activeSequence.description) + : activeSequence.description} +

+
+ + {t('Mode de lecture')} · {t(activeSequence.playbackMode)} + +
+
+
+ {t('Expressions')} + {activeSequence.steps.length} + + {activeSequence.steps + .map(step => formatSeconds(step.holdMs, language)) + .join(' · ')} + +
+
+ {t('Premier clignement')} + + {activeSequence.blink.enabled + ? formatSeconds(activeSequence.blink.initialDelayMs, language) + : t('Désactivé')} + + {t('après le lancement')} +
+
+ {t('Intervalle du clignement')} + + {formatSeconds(activeSequence.blink.minIntervalMs, language)}– + {formatSeconds(activeSequence.blink.maxIntervalMs, language)} + + {t('tirage aléatoire')} +
+
+ {t('Durée du clignement')} + {activeSequence.blink.durationMs} ms + {t('fermeture et ouverture')} +
+
+
+ )} +
+
+ {activeSequence.steps.map((step, position) => { + const expressionIndex = findExpressionIndex(expressions, step.expressionId) + const preset = expressions[expressionIndex] + if (!preset) return null + return ( + + ) + })} +
+
+ +
+
+
+ )} +
+ ) +} diff --git a/src/features/studio/components/StudioStage.tsx b/src/features/studio/components/StudioStage.tsx new file mode 100644 index 0000000..dacce16 --- /dev/null +++ b/src/features/studio/components/StudioStage.tsx @@ -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 ( + +
+ + Bible Strong Avatar Lab +
+
+ + +
+ { + 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 && ( +
+ ) +} diff --git a/src/features/studio/components/StudioView.tsx b/src/features/studio/components/StudioView.tsx new file mode 100644 index 0000000..747e2f0 --- /dev/null +++ b/src/features/studio/components/StudioView.tsx @@ -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 ( +
+ + + +
+ ) +} diff --git a/src/defaultStudioDocument.json b/src/features/studio/defaultStudioDocument.json similarity index 100% rename from src/defaultStudioDocument.json rename to src/features/studio/defaultStudioDocument.json diff --git a/src/studioDocument.ts b/src/features/studio/studioDocument.ts similarity index 95% rename from src/studioDocument.ts rename to src/features/studio/studioDocument.ts index 4ce9572..70b8f4b 100644 --- a/src/studioDocument.ts +++ b/src/features/studio/studioDocument.ts @@ -3,9 +3,13 @@ import { parseExpressions, type AvatarBehaviorLibrary, type AvatarLibrary, -} from './avatars' -import type { Expression } from './geometry' -import { normalizeSequencesForExpressions, parseSequences, type AvatarSequence } from './sequences' +} from '../avatar/avatars' +import type { Expression } from '../avatar/geometry' +import { + normalizeSequencesForExpressions, + parseSequences, + type AvatarSequence, +} from '../animation/sequences' import defaultStudioDocument from './defaultStudioDocument.json' export type StatePlaybackSelection = { stateId: string | null; playing: boolean } diff --git a/src/features/studio/useStudioController.ts b/src/features/studio/useStudioController.ts new file mode 100644 index 0000000..9e50dee --- /dev/null +++ b/src/features/studio/useStudioController.ts @@ -0,0 +1,1766 @@ +import { animate, useMotionValue, useMotionValueEvent, useReducedMotion } from 'motion/react' +import { useEffect, useEffectEvent, useRef, useState } from 'react' + +import { useStudioLanguage } from '@/i18n' + +import { + AMBIENT_FRAME_MS, + bounded, + createExpressionId, + downloadBlob, + INSPECTOR_FRAME_MS, + interpolateHexColor, + poseWithAvatarEyes, + resolveColors, + RETARGET_BLEND_MS, + type ExportFormat, + type Highlight, + type Mode, + type PlaybackStatus, + type Side, + type SnapshotFormat, +} from '@/app/studio-utils' +import { + advancePlaybackTimeline, + beginPlayback, + createPlaybackTimeline, + pausePlaybackTimeline, + schedulePlaybackBlink, + schedulePlaybackStep, + stopPlaybackTimeline, +} from '@/features/animation/playback' +import { + createSequence, + duplicateSequence, + findExpressionIndex, + getSequenceSpring, + readSequenceClock, + remapSequencesAfterExpressionDelete, + type AvatarSequence, + type SequenceStep, +} from '@/features/animation/sequences' +import { + ambientBodyOffset, + applyAmbientMotion, + hasAmbientMotion, +} from '@/features/avatar/ambientMotion' +import { + cloneAvatarBehavior, + createAvatar, + defaultAvatarEyes, + resolveAvatarBehavior, + type AvatarBehaviorLibrary, + type AvatarColors, + type AvatarEyeDefaults, + type StudioAvatar, +} from '@/features/avatar/avatars' +import { + bodyPrimitiveTypes, + createBodyNode, + duplicateBodyNode, + MAX_BODY_NODES, + type BodyNode, +} from '@/features/avatar/body' +import { + scaleEye, + updateEyeDimension, + updateEyePosition, +} from '@/features/avatar/expressionEditing' +import { + expressionFields, + poseFromExpression, + renderAvatar, + type AvatarPose, + type Expression, +} from '@/features/avatar/geometry' +import { defaultExpression } from '@/features/avatar/presets' +import { type SurfaceConfig } from '@/features/avatar/surfaces' +import { + avatarExportFileName, + createAvatarExportPayload, + generateJavaScriptAvatarPackage, + generateReactAvatarPackage, +} from '@/features/export/exporter' +import { + serializeAvatarSnapshot, + snapshotFileName, + type SnapshotBackground, +} from '@/features/export/snapshotExporter' +import { + resolveCanvasPreviewExpression, + type CanvasPreviewTarget, +} from '@/features/rendering/canvasPreview' +import { + createRenderedRotationGizmo, + paintRenderedRotationGizmo, +} from '@/features/rendering/renderedRotationGizmo' +import { + createRenderedColors, + createRenderedScene, + paintRenderedColors, + paintRenderedOffset, + paintRenderedScene, +} from '@/features/rendering/renderedScene' +import { + createStudioDocumentStore, + loadStudioDocument, + parseImportedStudioDocument, + persistStudioDocument, + serializeStudioDocument, + type StatePlaybackSelection, + type StudioDocument, +} from '@/features/studio/studioDocument' + +export function useStudioController() { + const { language, setLanguage, t } = useStudioLanguage() + const [mode, setMode] = useState('manual') + const [initialDocument] = useState(loadStudioDocument) + const [documentStore] = useState(() => createStudioDocumentStore(initialDocument)) + const initialLibrary = initialDocument.library + const [avatars, setAvatars] = useState(initialLibrary.avatars) + const [activeAvatarId, setActiveAvatarId] = useState(initialLibrary.activeAvatarId) + const initialAvatar = + initialLibrary.avatars.find(avatar => avatar.id === initialLibrary.activeAvatarId) ?? + initialLibrary.avatars[0] + const [baseBehavior] = useState(() => ({ + expressions: initialDocument.expressions, + sequences: initialDocument.sequences, + })) + const initialBehavior = resolveAvatarBehavior(initialAvatar, baseBehavior) + const [surface, setSurface] = useState(initialAvatar.body.primary) + const [bodyNodes, setBodyNodes] = useState(initialAvatar.body.nodes) + const [selectedBodyNodeId, setSelectedBodyNodeId] = useState<'primary' | string | null>('primary') + const [selectedEyeSide, setSelectedEyeSide] = useState<-1 | 1 | null>(null) + const [expressions, setExpressions] = useState(initialBehavior.expressions) + const [sequences, setSequences] = useState(initialBehavior.sequences) + const [exportFormat, setExportFormat] = useState('react') + const [exportAnimationIds, setExportAnimationIds] = useState(() => + initialBehavior.sequences.map(animation => animation.id) + ) + const [snapshotBackground, setSnapshotBackground] = useState('transparent') + const [snapshotColorFrom, setSnapshotColorFrom] = useState('#F5F7FC') + const [snapshotColorTo, setSnapshotColorTo] = useState('#C9D5FF') + const [snapshotSize, setSnapshotSize] = useState('1024') + const [snapshotFormat, setSnapshotFormat] = useState('png') + const [photoFlash, setPhotoFlash] = useState(0) + const initialStatePlayback = initialDocument.playback + const updateStudioLibrary = (library: typeof initialDocument.library) => + documentStore.update({ library }) + const persistStatePlayback = (playback: StatePlaybackSelection) => + documentStore.update({ playback }) + const [bodyEditing, setBodyEditing] = useState(false) + const modeRef = useRef(mode) + useEffect(() => { + modeRef.current = mode + }, [mode]) + const avatarEditSnapshot = useRef<{ + avatars: StudioAvatar[] + activeAvatarId: string + } | null>(null) + const workspaceBackButtonRef = useRef(null) + const [focusAvatarName, setFocusAvatarName] = useState(false) + const initialExpression = expressions[0] ?? defaultExpression + const [expression, setExpression] = useState({ ...initialExpression }) + const initialDisplayColors = resolveColors(initialExpression, initialAvatar.colors) + const [renderedColors] = useState(() => createRenderedColors(initialDisplayColors)) + const setDisplayColors = (next: AvatarColors) => { + paintRenderedColors(renderedColors, next) + } + const [deleteAvatarOpen, setDeleteAvatarOpen] = useState(false) + const [deleteExpressionOpen, setDeleteExpressionOpen] = useState(false) + const [deleteSequenceOpen, setDeleteSequenceOpen] = useState(false) + const [pendingProjectImport, setPendingProjectImport] = useState<{ + document: StudioDocument + fileName: string + } | null>(null) + const [projectImportError, setProjectImportError] = useState(null) + const projectImportRef = useRef(null) + const [statePlayerExpanded, setStatePlayerExpanded] = useState(false) + const [activeExpression, setActiveExpression] = useState(null) + const [editing, setEditing] = useState<{ index: number | null; draft: Expression } | null>(null) + const [showWire, setShowWire] = useState(false) + const [springSpeed, setSpringSpeed] = useState(7) + const [linked, setLinked] = useState({ + width: true, + height: true, + size: true, + position: true, + rotation: true, + }) + const [highlight, setHighlight] = useState(null) + const [selectedState, setSelectedState] = useState(() => + sequences.some(sequence => sequence.id === initialStatePlayback.stateId) + ? initialStatePlayback.stateId! + : sequences.some(sequence => sequence.id === 'idle') + ? 'idle' + : (sequences[0]?.id ?? '') + ) + const [activeState, setActiveState] = useState(null) + const [statePlaying, setStatePlaying] = useState(false) + const [playbackStatus, setPlaybackStatus] = useState(() => + initialStatePlayback.stateId === null ? 'stopped' : 'paused' + ) + const [playbackVisual, setPlaybackVisual] = useState({ + position: null as number | null, + run: 0, + durationMs: 0, + }) + const [sequenceEditing, setSequenceEditing] = useState<{ + sourceId: string | null + draft: AvatarSequence + } | null>(null) + const [selectedSequenceStepId, setSelectedSequenceStepId] = useState(null) + const stateTimer = useRef | null>(null) + const activeSequenceRef = useRef(null) + const editorStateSnapshot = useRef<{ + stateId: string + playing: boolean + expression: Expression + } | null>(null) + const initialStatePlaybackApplied = useRef(false) + const blinkTimer = useRef | null>(null) + const [initialPlaybackTimeline] = useState(createPlaybackTimeline) + const playbackTimeline = useRef(initialPlaybackTimeline) + const reduceMotion = useReducedMotion() + + const avatarsRef = useRef(avatars) + const expressionsRef = useRef(expressions) + const sequencesRef = useRef(sequences) + const draggedAvatarId = useRef(null) + const avatarDragOrigin = useRef(null) + const avatarDragPreview = useRef(avatars) + const [draggingAvatarId, setDraggingAvatarId] = useState(null) + const draggedExpressionId = useRef(null) + const expressionDragOrigin = useRef(null) + const expressionDragPreview = useRef(expressions) + const [draggingExpressionId, setDraggingExpressionId] = useState(null) + const draggedStateId = useRef(null) + const stateDragOrigin = useRef(null) + const stateDragPreview = useRef(sequences) + const [draggingStateId, setDraggingStateId] = useState(null) + const activeAvatarIdRef = useRef(activeAvatarId) + const surfaceRef = useRef(surface) + const bodyNodesRef = useRef(bodyNodes) + const showWireRef = useRef(showWire) + const highlightRef = useRef(highlight) + const persistActiveBehavior = ( + nextExpressions: Expression[], + nextSequences: AvatarSequence[] + ) => { + const behavior = cloneAvatarBehavior({ + expressions: nextExpressions, + sequences: nextSequences, + }) + const nextAvatars = avatarsRef.current.map(avatar => + avatar.id === activeAvatarIdRef.current ? { ...avatar, behavior } : avatar + ) + avatarsRef.current = nextAvatars + setAvatars(nextAvatars) + updateStudioLibrary({ activeAvatarId: activeAvatarIdRef.current, avatars: nextAvatars }) + } + const updateStudioExpressions = (nextExpressions: Expression[]) => { + expressionsRef.current = nextExpressions + persistActiveBehavior(nextExpressions, sequencesRef.current) + } + const updateStudioSequences = (nextSequences: AvatarSequence[]) => { + sequencesRef.current = nextSequences + persistActiveBehavior(expressionsRef.current, nextSequences) + } + const [initialRender] = useState(() => { + const pose = poseFromExpression(initialExpression) + return { + pose, + geometry: renderAvatar( + poseWithAvatarEyes(initialExpression, initialAvatar.eyes), + surface, + 1, + { bodyNodes } + ), + } + }) + const { pose: initialPose, geometry: initialGeometry } = initialRender + const displayedPose = useRef(initialPose) + const transitionFrame = useRef(null) + const ambientFrame = useRef(null) + const ambientStartedAt = useRef(0) + const lastAmbientElapsed = useRef(0) + const lastAmbientFrame = useRef(0) + const ambientSignature = useRef('none:none') + const transitionTarget = useRef({ ...initialExpression }) + const canonicalTarget = useRef({ ...initialExpression }) + const retargetFrom = useRef(null) + const retargetTo = useRef(null) + const retargetStartedAt = useRef(null) + const [transitionVelocity] = useState( + () => + Object.fromEntries(expressionFields.map(field => [field, 0])) as Record< + (typeof expressionFields)[number], + number + > + ) + const lastTransitionTime = useRef(null) + const lastInspectorFrame = useRef(0) + const springSpeedRef = useRef(springSpeed) + const sequenceTransitionRef = useRef>({ + transitionMs: 500, + transition: 'smooth', + }) + const activeSequenceTransition = useRef<{ + target: Expression + index: number | null + settings: Pick + remainingMs: number + } | null>(null) + const pausedSequenceTransition = useRef(null) + const blinkControls = useRef | null>(null) + const blinkAnimating = useRef(false) + const blinkValue = useMotionValue(1) + const [renderedScene] = useState(() => createRenderedScene(initialGeometry)) + const [renderedRotationGizmo] = useState(() => createRenderedRotationGizmo(initialExpression)) + const bodyColorAnimation = useRef | null>(null) + const eyeColorAnimation = useRef | null>(null) + + const paintPose = (pose: AvatarPose, blink?: number, frameTimeMs?: number) => { + displayedPose.current = pose + paintRenderedRotationGizmo(renderedRotationGizmo, pose.expression) + const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) + const signature = `${pose.expression.eyeMotion}:${pose.expression.bodyMotion}` + if (signature !== ambientSignature.current) { + ambientSignature.current = signature + ambientStartedAt.current = -1 + lastAmbientElapsed.current = 0 + } + if (frameTimeMs !== undefined) { + if (ambientStartedAt.current < 0) ambientStartedAt.current = frameTimeMs + lastAmbientElapsed.current = frameTimeMs - ambientStartedAt.current + } + const renderedExpression = + !reduceMotion && hasAmbientMotion(pose.expression) + ? applyAmbientMotion(pose.expression, lastAmbientElapsed.current) + : pose.expression + const renderPose = avatar + ? poseWithAvatarEyes(renderedExpression, avatar.eyes ?? defaultAvatarEyes) + : poseFromExpression(renderedExpression) + const geometry = renderAvatar(renderPose, surfaceRef.current, blink ?? blinkValue.get(), { + includeWire: showWireRef.current || highlightRef.current === 'head', + bodyNodes: bodyNodesRef.current, + }) + paintRenderedScene(renderedScene, geometry) + paintRenderedOffset( + renderedScene, + ambientBodyOffset(pose.expression, lastAmbientElapsed.current) + ) + } + + useMotionValueEvent(blinkValue, 'change', latest => paintPose(displayedPose.current, latest)) + + const paintAmbientFrame = useEffectEvent((time: number) => { + if (transitionFrame.current === null && time - lastAmbientFrame.current >= AMBIENT_FRAME_MS) { + lastAmbientFrame.current = time + paintPose(displayedPose.current, undefined, time) + } + }) + + const ambientLoopActive = !reduceMotion && hasAmbientMotion(editing?.draft ?? expression) + useEffect(() => { + if (!ambientLoopActive) return + const tick = (time: number) => { + paintAmbientFrame(time) + ambientFrame.current = requestAnimationFrame(tick) + } + ambientFrame.current = requestAnimationFrame(tick) + return () => { + if (ambientFrame.current !== null) cancelAnimationFrame(ambientFrame.current) + } + }, [ambientLoopActive]) + + useEffect( + () => () => { + if (transitionFrame.current !== null) cancelAnimationFrame(transitionFrame.current) + blinkControls.current?.stop() + bodyColorAnimation.current?.stop() + eyeColorAnimation.current?.stop() + if (stateTimer.current) clearTimeout(stateTimer.current) + if (blinkTimer.current) clearTimeout(blinkTimer.current) + }, + [] + ) + + const stopTransition = (resetVelocity: boolean) => { + if (transitionFrame.current !== null) cancelAnimationFrame(transitionFrame.current) + transitionFrame.current = null + lastTransitionTime.current = null + retargetFrom.current = null + retargetTo.current = null + retargetStartedAt.current = null + lastInspectorFrame.current = 0 + if (resetVelocity) { + expressionFields.forEach(field => { + transitionVelocity[field] = 0 + }) + } + } + + const stopColorTransitions = () => { + bodyColorAnimation.current?.stop() + eyeColorAnimation.current?.stop() + bodyColorAnimation.current = null + eyeColorAnimation.current = null + } + + const freezeLivePreviewForManipulation = () => { + if (statePlaying) pauseState() + const renderedExpression = { ...displayedPose.current.expression } + stopTransition(true) + stopColorTransitions() + activeSequenceTransition.current = null + pausedSequenceTransition.current = null + transitionTarget.current = renderedExpression + canonicalTarget.current = renderedExpression + setExpression(renderedExpression) + setActiveExpression(null) + return renderedExpression + } + + const updateImmediate = (next: Expression, preservePlayback = false) => { + if (!preservePlayback && statePlaying) pauseState() + stopTransition(true) + stopColorTransitions() + const pose = poseFromExpression(next) + transitionTarget.current = next + canonicalTarget.current = next + setExpression(next) + const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) + if (avatar) setDisplayColors(resolveColors(next, avatar.colors)) + setActiveExpression(null) + paintPose(pose) + } + + const transitionToExpression = ( + next: Expression, + index: number | null = null, + transitionSettings?: Pick + ) => { + if (!transitionSettings && statePlaying) pauseState() + sequenceTransitionRef.current = transitionSettings ?? { + transitionMs: 500, + transition: 'smooth', + } + setActiveExpression(index) + const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) + if (reduceMotion || transitionSettings?.transitionMs === 0) { + updateImmediate(next, Boolean(transitionSettings)) + setActiveExpression(index) + return + } + const current = displayedPose.current.expression + const nearestAngle = (target: number, from: number) => { + let resolved = target + while (resolved - from > 180) resolved -= 360 + while (resolved - from < -180) resolved += 360 + return resolved + } + canonicalTarget.current = next + const resolvedTarget = { + ...next, + headX: nearestAngle(next.headX, current.headX), + headY: nearestAngle(next.headY, current.headY), + headZ: nearestAngle(next.headZ, current.headZ), + leftAngle: nearestAngle(next.leftAngle, current.leftAngle), + rightAngle: nearestAngle(next.rightAngle, current.rightAngle), + } + + if (transitionSettings) { + stopTransition(true) + stopColorTransitions() + const durationMs = transitionSettings.transitionMs + const from = { ...current } + const fromColors = { + body: renderedColors.body.get(), + eyes: renderedColors.eyes.get(), + } + const targetColors = avatar ? resolveColors(next, avatar.colors) : fromColors + let startedAt: number | null = null + activeSequenceTransition.current = { + target: next, + index, + settings: transitionSettings, + remainingMs: durationMs, + } + const tickSequenceTransition = (time: number) => { + if (startedAt === null) startedAt = time + const elapsed = time - startedAt + const progress = Math.min(elapsed / durationMs, 1) + const eased = + transitionSettings.transition === 'smooth' + ? progress * progress * (3 - 2 * progress) + : transitionSettings.transition === 'snappy' + ? 1 - (1 - progress) ** 3 + : 1 - Math.exp(-6 * progress) * Math.cos(8 * progress) + const animated = { ...from, eyeMotion: next.eyeMotion, bodyMotion: next.bodyMotion } + expressionFields.forEach(field => { + animated[field] = from[field] + (resolvedTarget[field] - from[field]) * eased + }) + activeSequenceTransition.current = { + target: next, + index, + settings: transitionSettings, + remainingMs: Math.max(durationMs - elapsed, 0), + } + setDisplayColors({ + body: interpolateHexColor(fromColors.body, targetColors.body, bounded(eased, 0, 1)), + eyes: interpolateHexColor(fromColors.eyes, targetColors.eyes, bounded(eased, 0, 1)), + }) + paintPose(poseFromExpression(animated), undefined, time) + if ( + modeRef.current === 'manual' && + time - lastInspectorFrame.current >= INSPECTOR_FRAME_MS + ) { + lastInspectorFrame.current = time + setExpression(animated) + } + if (progress < 1) { + transitionFrame.current = requestAnimationFrame(tickSequenceTransition) + return + } + transitionFrame.current = null + activeSequenceTransition.current = null + canonicalTarget.current = next + transitionTarget.current = next + setExpression(next) + setDisplayColors(targetColors) + paintPose(poseFromExpression(next)) + } + transitionFrame.current = requestAnimationFrame(tickSequenceTransition) + return + } + + if (avatar) { + const targetColors = resolveColors(next, avatar.colors) + bodyColorAnimation.current = animate(renderedColors.body, targetColors.body, { + duration: 0.35, + ease: 'easeInOut', + }) + eyeColorAnimation.current = animate(renderedColors.eyes, targetColors.eyes, { + duration: 0.35, + ease: 'easeInOut', + }) + } + + if (transitionFrame.current !== null) { + retargetFrom.current = { ...transitionTarget.current } + retargetTo.current = resolvedTarget + retargetStartedAt.current = -1 + return + } + transitionTarget.current = resolvedTarget + const tick = (time: number) => { + const previousTime = lastTransitionTime.current ?? time + const deltaTime = Math.min(Math.max((time - previousTime) / 1000, 1 / 240), 1 / 30) + lastTransitionTime.current = time + const { stiffness, damping } = getSequenceSpring( + sequenceTransitionRef.current.transition, + sequenceTransitionRef.current.transitionMs, + springSpeedRef.current + ) + const mass = 0.85 + const currentExpression = displayedPose.current.expression + if (retargetStartedAt.current !== null && retargetFrom.current && retargetTo.current) { + if (retargetStartedAt.current < 0) retargetStartedAt.current = time + const linearProgress = Math.min((time - retargetStartedAt.current) / RETARGET_BLEND_MS, 1) + const smoothProgress = + linearProgress ** 3 * (linearProgress * (linearProgress * 6 - 15) + 10) + const blendedTarget = { ...retargetTo.current } + expressionFields.forEach(field => { + blendedTarget[field] = + retargetFrom.current![field] + + (retargetTo.current![field] - retargetFrom.current![field]) * smoothProgress + }) + transitionTarget.current = blendedTarget + if (linearProgress === 1) { + transitionTarget.current = retargetTo.current + retargetFrom.current = null + retargetTo.current = null + retargetStartedAt.current = null + } + } + const target = transitionTarget.current + let settled = true + const animated = { ...currentExpression } + animated.eyeMotion = target.eyeMotion + animated.bodyMotion = target.bodyMotion + + expressionFields.forEach(field => { + const displacement = target[field] - currentExpression[field] + const acceleration = (stiffness * displacement - damping * transitionVelocity[field]) / mass + const velocity = transitionVelocity[field] + acceleration * deltaTime + const value = currentExpression[field] + velocity * deltaTime + transitionVelocity[field] = velocity + animated[field] = value + const tolerance = field === 'perspective' ? 0.0001 : 0.005 + if (Math.abs(displacement) > tolerance || Math.abs(velocity) > tolerance) settled = false + }) + + if (settled) { + const finalExpression = canonicalTarget.current + stopTransition(true) + setExpression(finalExpression) + paintPose(poseFromExpression(finalExpression)) + return + } + + paintPose(poseFromExpression(animated), undefined, time) + if (modeRef.current === 'manual' && time - lastInspectorFrame.current >= INSPECTOR_FRAME_MS) { + lastInspectorFrame.current = time + setExpression(animated) + } + transitionFrame.current = requestAnimationFrame(tick) + } + transitionFrame.current = requestAnimationFrame(tick) + } + + const blink = (durationMs?: number) => { + blinkControls.current?.stop() + blinkValue.jump(1) + blinkAnimating.current = true + const sequence = sequences.find(item => item.id === (activeState ?? selectedState)) + const blinkDuration = durationMs ?? sequence?.blink.durationMs ?? 280 + blinkControls.current = animate(blinkValue, [1, 0, 1], { + duration: reduceMotion ? 0 : blinkDuration / 1000, + times: [0, 0.42, 1], + ease: ['easeIn', 'easeOut'], + onComplete: () => { + blinkAnimating.current = false + }, + }) + } + + const updateDimension = (side: Side, dimension: 'width' | 'height', value: number) => { + updateImmediate(updateEyeDimension(expression, side, dimension, value, linked[dimension])) + } + + const updateSize = (side: Side, value: number) => { + updateImmediate(scaleEye(expression, side, value, linked.size)) + } + + const updateSpacing = (value: number) => { + updateImmediate({ ...expression, spacing: value }) + } + + const updateActiveAvatar = (update: (avatar: StudioAvatar) => StudioAvatar) => { + const next = avatarsRef.current.map(avatar => + avatar.id === activeAvatarIdRef.current ? update(avatar) : avatar + ) + avatarsRef.current = next + setAvatars(next) + if (!avatarEditSnapshot.current) { + updateStudioLibrary({ activeAvatarId: activeAvatarIdRef.current, avatars: next }) + } + } + + const selectBodyNode = (id: 'primary' | string | null) => { + setSelectedBodyNodeId(id) + if (id) setSelectedEyeSide(null) + } + + const updateSurface = (next: SurfaceConfig) => { + surfaceRef.current = next + setSurface(next) + updateActiveAvatar(avatar => ({ + ...avatar, + body: { primary: next, nodes: bodyNodesRef.current }, + })) + paintPose(displayedPose.current) + } + + const updateBodyNodes = (next: BodyNode[]) => { + bodyNodesRef.current = next + setBodyNodes(next) + updateActiveAvatar(avatar => ({ + ...avatar, + body: { primary: surfaceRef.current, nodes: next }, + })) + paintPose(displayedPose.current) + } + + const updateAvatarColors = (changes: Partial) => { + const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) + if (!avatar) return + const colors = { ...avatar.colors, ...changes } + updateActiveAvatar(current => ({ ...current, colors })) + setDisplayColors(resolveColors(expression, colors)) + } + + const updateAvatarEyes = (changes: Partial) => { + const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) + if (!avatar) return + const eyes = { ...(avatar.eyes ?? defaultAvatarEyes), ...changes } + updateActiveAvatar(current => ({ ...current, eyes })) + paintPose(displayedPose.current) + } + + const activateAvatar = (id: string, editBody = false, preserveMode = false) => { + const avatar = avatarsRef.current.find(item => item.id === id) + if (!avatar) return + const resumeActiveSequence = statePlaying + if (resumeActiveSequence) pauseState(false) + if (editBody) suspendStateForEditor() + if (editBody && !avatarEditSnapshot.current) { + avatarEditSnapshot.current = { + avatars: avatarsRef.current, + activeAvatarId: activeAvatarIdRef.current, + } + } + const currentStateExpression = displayedPose.current.expression + const nextBehavior = resolveAvatarBehavior(avatar, baseBehavior) + const nextExpressions = nextBehavior.expressions + const nextSequences = nextBehavior.sequences + stopTransition(true) + activeAvatarIdRef.current = id + surfaceRef.current = avatar.body.primary + bodyNodesRef.current = avatar.body.nodes + setActiveAvatarId(id) + setSurface(avatar.body.primary) + setBodyNodes(avatar.body.nodes) + expressionsRef.current = nextExpressions + sequencesRef.current = nextSequences + expressionDragPreview.current = nextExpressions + stateDragPreview.current = nextSequences + setExpressions(nextExpressions) + setSequences(nextSequences) + setExportAnimationIds(nextSequences.map(animation => animation.id)) + const nextActiveSequence = activeState + ? (nextSequences.find(sequence => sequence.id === activeState) ?? null) + : null + const nextSelectedState = + nextActiveSequence?.id ?? + (nextSequences.some(sequence => sequence.id === 'idle') + ? 'idle' + : (nextSequences[0]?.id ?? '')) + activeSequenceRef.current = nextActiveSequence + setActiveState(nextActiveSequence?.id ?? null) + setSelectedState(nextSelectedState) + setPlaybackVisual(current => ({ ...current, position: null })) + selectBodyNode('primary') + setActiveExpression(null) + setEditing(null) + setBodyEditing(editBody) + if (!preserveMode || editBody) setMode('manual') + const nextExpression = nextActiveSequence + ? currentStateExpression + : { ...(nextExpressions[0] ?? defaultExpression) } + setExpression(nextExpression) + setDisplayColors(resolveColors(nextExpression, avatar.colors)) + canonicalTarget.current = nextExpression + transitionTarget.current = nextExpression + paintPose(poseFromExpression(nextExpression)) + if (nextActiveSequence && resumeActiveSequence) { + pausedSequenceTransition.current = null + launchSequence(nextActiveSequence, true, false) + } + if (!avatarEditSnapshot.current) { + updateStudioLibrary({ activeAvatarId: id, avatars: avatarsRef.current }) + } + } + + const createNewAvatar = () => { + avatarEditSnapshot.current = { + avatars: avatarsRef.current, + activeAvatarId: activeAvatarIdRef.current, + } + const avatar = createAvatar('Unknown') + const next = [...avatarsRef.current, avatar] + avatarsRef.current = next + setAvatars(next) + setFocusAvatarName(true) + activateAvatar(avatar.id, true) + } + + const duplicateAvatar = (source: StudioAvatar, editDuplicate = false) => { + const snapshotAvatars = avatarEditSnapshot.current?.avatars ?? avatarsRef.current + const sourceIndex = snapshotAvatars.findIndex(avatar => avatar.id === source.id) + const baseAvatars = sourceIndex < 0 ? [...snapshotAvatars, source] : snapshotAvatars + const duplicate: StudioAvatar = { + ...structuredClone(source), + id: `avatar-${crypto.randomUUID()}`, + name: `${source.name} ${t('copie')}`, + } + const next = [...baseAvatars, duplicate] + avatarEditSnapshot.current = null + avatarsRef.current = next + setAvatars(next) + updateStudioLibrary({ activeAvatarId: duplicate.id, avatars: next }) + activateAvatar(duplicate.id, editDuplicate) + } + + const previewAvatarMove = (targetId: string) => { + const draggedId = draggedAvatarId.current + if (!draggedId || draggedId === targetId) return + const current = avatarDragPreview.current + const dragged = current.find(avatar => avatar.id === draggedId) + const targetIndex = current.findIndex(avatar => avatar.id === targetId) + if (!dragged || targetIndex < 0) return + const next = current.filter(avatar => avatar.id !== draggedId) + next.splice(targetIndex, 0, dragged) + avatarDragPreview.current = next + setAvatars(next) + } + + const commitAvatarMove = (targetId: string) => { + previewAvatarMove(targetId) + const next = avatarDragPreview.current + avatarsRef.current = next + updateStudioLibrary({ activeAvatarId: activeAvatarIdRef.current, avatars: next }) + avatarDragOrigin.current = null + draggedAvatarId.current = null + setDraggingAvatarId(null) + } + + const cancelAvatarMove = () => { + if (draggedAvatarId.current && avatarDragOrigin.current) { + avatarDragPreview.current = avatarDragOrigin.current + setAvatars(avatarDragOrigin.current) + } + avatarDragOrigin.current = null + draggedAvatarId.current = null + setDraggingAvatarId(null) + } + + const cancelAvatarEditing = () => { + const snapshot = avatarEditSnapshot.current + if (!snapshot) { + setBodyEditing(false) + restoreStateAfterEditor() + return + } + avatarEditSnapshot.current = null + avatarsRef.current = snapshot.avatars + setAvatars(snapshot.avatars) + activateAvatar(snapshot.activeAvatarId, false, true) + restoreStateAfterEditor() + } + + const saveAvatarEditing = () => { + avatarEditSnapshot.current = null + updateStudioLibrary({ activeAvatarId: activeAvatarIdRef.current, avatars: avatarsRef.current }) + setBodyEditing(false) + restoreStateAfterEditor() + } + + const renameActiveAvatar = (name: string) => { + updateActiveAvatar(avatar => ({ ...avatar, name })) + } + + const deleteActiveAvatar = () => { + if (avatarsRef.current.length <= 1) return + avatarEditSnapshot.current = null + const remaining = avatarsRef.current.filter(avatar => avatar.id !== activeAvatarIdRef.current) + avatarsRef.current = remaining + setAvatars(remaining) + updateStudioLibrary({ activeAvatarId: remaining[0].id, avatars: remaining }) + setDeleteAvatarOpen(false) + activateAvatar(remaining[0].id) + restoreStateAfterEditor() + } + + const addBodyNode = (type: (typeof bodyPrimitiveTypes)[number]) => { + if (bodyNodesRef.current.length >= MAX_BODY_NODES) return + const node = createBodyNode(type, bodyNodesRef.current.length) + updateBodyNodes([...bodyNodesRef.current, node]) + selectBodyNode(node.id) + } + + const updateSelectedBodyNode = (update: (node: BodyNode) => BodyNode) => { + if (selectedBodyNodeId === 'primary') return + updateBodyNodes( + bodyNodesRef.current.map(node => (node.id === selectedBodyNodeId ? update(node) : node)) + ) + } + + const commitBodyNode = (nextNode: BodyNode) => { + updateBodyNodes(bodyNodesRef.current.map(node => (node.id === nextNode.id ? nextNode : node))) + } + + const previewSelectedBodyNode = (nextNode: BodyNode) => { + const next = bodyNodesRef.current.map(node => (node.id === nextNode.id ? nextNode : node)) + bodyNodesRef.current = next + setBodyNodes(next) + paintPose(displayedPose.current) + } + + const deleteSelectedBodyNode = () => { + if (selectedBodyNodeId === 'primary') return + updateBodyNodes(bodyNodesRef.current.filter(node => node.id !== selectedBodyNodeId)) + selectBodyNode('primary') + } + + const duplicateSelectedBodyNode = () => { + if (selectedBodyNodeId === 'primary' || bodyNodesRef.current.length >= MAX_BODY_NODES) return + const source = bodyNodesRef.current.find(node => node.id === selectedBodyNodeId) + if (!source) return + const duplicate = duplicateBodyNode(source) + updateBodyNodes([...bodyNodesRef.current, duplicate]) + selectBodyNode(duplicate.id) + } + + const updateHighlight = (next: Highlight) => { + if (next && statePlaying) pauseState() + highlightRef.current = next + setHighlight(next) + if (next === 'head') paintPose(displayedPose.current) + } + + const updateWireVisibility = (next: boolean) => { + showWireRef.current = next + setShowWire(next) + if (next) paintPose(displayedPose.current) + } + + const clearStateTimers = () => { + if (stateTimer.current) clearTimeout(stateTimer.current) + if (blinkTimer.current) clearTimeout(blinkTimer.current) + stateTimer.current = null + blinkTimer.current = null + playbackTimeline.current = { + ...playbackTimeline.current, + stepDueAt: null, + blinkDueAt: null, + } + } + + const pauseState = (persist = true) => { + playbackTimeline.current = pausePlaybackTimeline(playbackTimeline.current, readSequenceClock()) + if (transitionFrame.current !== null && activeSequenceTransition.current) { + cancelAnimationFrame(transitionFrame.current) + transitionFrame.current = null + pausedSequenceTransition.current = activeSequenceTransition.current + activeSequenceTransition.current = null + } + if (blinkAnimating.current) blinkControls.current?.pause() + clearStateTimers() + setStatePlaying(false) + setPlaybackStatus('paused') + if (persist && activeState) persistStatePlayback({ stateId: activeState, playing: false }) + } + + const stopState = (persist = true) => { + clearStateTimers() + playbackTimeline.current = stopPlaybackTimeline(playbackTimeline.current) + activeSequenceTransition.current = null + pausedSequenceTransition.current = null + stopTransition(true) + blinkControls.current?.stop() + blinkAnimating.current = false + blinkValue.jump(1) + paintPose(displayedPose.current, 1) + setStatePlaying(false) + setPlaybackStatus('stopped') + setPlaybackVisual(current => ({ ...current, position: null })) + if (persist) persistStatePlayback({ stateId: null, playing: false }) + } + + const launchSequence = (sequence: AvatarSequence, resume = false, persist = true) => { + clearStateTimers() + if (!sequence.steps.length) { + stopState(persist) + return + } + const id = sequence.id + playbackTimeline.current = beginPlayback(playbackTimeline.current, resume) + setSelectedState(id) + setActiveState(id) + activeSequenceRef.current = sequence + setStatePlaying(true) + setPlaybackStatus('playing') + if (persist) persistStatePlayback({ stateId: id, playing: true }) + const scheduleAdvance = (delay: number) => { + playbackTimeline.current = schedulePlaybackStep( + playbackTimeline.current, + readSequenceClock(), + delay + ) + stateTimer.current = setTimeout(advance, delay) + } + const playCurrentStep = () => { + playbackTimeline.current = { ...playbackTimeline.current, stepDueAt: null } + const step = sequence.steps[playbackTimeline.current.position] + const availableExpressions = expressionsRef.current + const expressionIndex = findExpressionIndex(availableExpressions, step.expressionId) + const preset = availableExpressions[expressionIndex] + const durationMs = (reduceMotion ? 0 : step.transitionMs) + step.holdMs + setPlaybackVisual(current => ({ + position: playbackTimeline.current.position, + run: current.run + 1, + durationMs, + })) + if (preset) { + transitionToExpression(preset, expressionIndex, step) + } + scheduleAdvance(durationMs) + } + const advance = () => { + const advanced = advancePlaybackTimeline(playbackTimeline.current, sequence) + playbackTimeline.current = advanced.timeline + const { cursor } = advanced + if (cursor.complete) { + if (blinkTimer.current) clearTimeout(blinkTimer.current) + blinkTimer.current = null + playbackTimeline.current = { ...playbackTimeline.current, blinkDueAt: null } + setStatePlaying(false) + setPlaybackStatus('stopped') + setPlaybackVisual(current => ({ ...current, position: null })) + if (persist) persistStatePlayback({ stateId: null, playing: false }) + return + } + playCurrentStep() + } + const scheduleBlink = (delay: number) => { + playbackTimeline.current = schedulePlaybackBlink( + playbackTimeline.current, + readSequenceClock(), + delay + ) + blinkTimer.current = setTimeout(blinkLoop, delay) + } + const blinkLoop = () => { + playbackTimeline.current = { ...playbackTimeline.current, blinkDueAt: null } + blink(sequence.blink.durationMs) + const { minIntervalMs, maxIntervalMs } = sequence.blink + scheduleBlink( + sequence.blink.durationMs + minIntervalMs + Math.random() * (maxIntervalMs - minIntervalMs) + ) + } + if (resume) { + const pausedTransition = pausedSequenceTransition.current + if (pausedTransition) { + transitionToExpression(pausedTransition.target, pausedTransition.index, { + ...pausedTransition.settings, + transitionMs: pausedTransition.remainingMs, + }) + pausedSequenceTransition.current = null + } + if (blinkAnimating.current) blinkControls.current?.play() + const currentStep = sequence.steps[playbackTimeline.current.position] + scheduleAdvance( + playbackTimeline.current.stepRemainingMs || + (reduceMotion ? 0 : currentStep.transitionMs) + currentStep.holdMs + ) + } else { + playCurrentStep() + } + if (sequence.blink.enabled) { + scheduleBlink( + resume + ? playbackTimeline.current.blinkRemainingMs || sequence.blink.initialDelayMs + : sequence.blink.initialDelayMs + ) + } + } + + const toggleStatePlayback = () => { + if (!activeState || !activeSequenceRef.current) return + if (statePlaying) pauseState() + else launchSequence(activeSequenceRef.current, playbackStatus === 'paused') + } + + const suspendStateForEditor = () => { + if (editorStateSnapshot.current || !activeState) return + editorStateSnapshot.current = { + stateId: activeState, + playing: statePlaying, + expression: { ...displayedPose.current.expression }, + } + if (statePlaying) pauseState(false) + setActiveState(null) + } + + const restoreStateAfterEditor = (availableSequences = sequences) => { + const snapshot = editorStateSnapshot.current + editorStateSnapshot.current = null + if (!snapshot) return + const sequence = availableSequences.find(item => item.id === snapshot.stateId) + if (!sequence) { + stopState(false) + persistStatePlayback({ stateId: null, playing: false }) + return + } + activeSequenceRef.current = sequence + setSelectedState(sequence.id) + setActiveState(sequence.id) + if (snapshot.playing) { + launchSequence(sequence, true, false) + return + } + setStatePlaying(false) + transitionToExpression(snapshot.expression) + } + + useEffect(() => { + if (initialStatePlaybackApplied.current) return + initialStatePlaybackApplied.current = true + const sequence = sequences.find(item => item.id === selectedState) + if (sequence && initialStatePlayback.stateId !== null) { + activeSequenceRef.current = sequence + setActiveState(sequence.id) + if (initialStatePlayback.playing) launchSequence(sequence, false, false) + else setPlaybackStatus('paused') + } + return () => { + initialStatePlaybackApplied.current = false + } + }, []) + + const saveEditing = () => { + if (!editing) return + const index = editing.index ?? expressions.length + const savedDraft = + editing.index === null ? { ...editing.draft, id: createExpressionId() } : editing.draft + const next = + editing.index === null + ? [...expressions, savedDraft] + : expressions.map((item, itemIndex) => + itemIndex === editing.index ? { ...savedDraft } : item + ) + setExpressions(next) + updateStudioExpressions(next) + setEditing(null) + transitionToExpression(savedDraft, index) + if (!sequenceEditing) restoreStateAfterEditor() + } + + const duplicateExpression = (_index: number | null, draft: Expression, editDuplicate = false) => { + const duplicate = { ...draft, id: createExpressionId() } + const next = [...expressions, duplicate] + const duplicateIndex = next.length - 1 + setExpressions(next) + updateStudioExpressions(next) + if (editDuplicate) openExpressionEditor(duplicateIndex, duplicate) + else transitionToExpression(duplicate, duplicateIndex) + } + + const previewExpressionMove = (targetId: string | null) => { + const draggedId = draggedExpressionId.current + if (!draggedId || draggedId === targetId) return + const current = expressionDragPreview.current + const dragged = current.find(item => item.id === draggedId) + if (!dragged) return + const activeId = activeExpression === null ? null : current[activeExpression]?.id + const next = current.filter(item => item.id !== draggedId) + const targetIndex = targetId ? next.findIndex(item => item.id === targetId) : next.length + next.splice(targetIndex < 0 ? next.length : targetIndex, 0, dragged) + expressionDragPreview.current = next + setExpressions(next) + if (activeId) setActiveExpression(next.findIndex(item => item.id === activeId)) + } + + const commitExpressionMove = (targetId: string | null) => { + previewExpressionMove(targetId) + updateStudioExpressions(expressionDragPreview.current) + expressionDragOrigin.current = null + draggedExpressionId.current = null + setDraggingExpressionId(null) + } + + const cancelExpressionMove = () => { + if (draggedExpressionId.current && expressionDragOrigin.current) { + expressionDragPreview.current = expressionDragOrigin.current + setExpressions(expressionDragOrigin.current) + } + expressionDragOrigin.current = null + draggedExpressionId.current = null + setDraggingExpressionId(null) + } + + const previewExpressionDraft = (draft: Expression) => { + setEditing(current => (current ? { ...current, draft } : current)) + const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) + if (avatar) setDisplayColors(resolveColors(draft, avatar.colors)) + paintPose(poseFromExpression(draft)) + } + + const previewCanvasExpression = (next: Expression, target: CanvasPreviewTarget) => { + const now = performance.now() + const updateInspector = now - lastInspectorFrame.current >= INSPECTOR_FRAME_MS + if (updateInspector) { + lastInspectorFrame.current = now + if (editing) { + setEditing(current => (current ? { ...current, draft: next } : current)) + } else { + setExpression(next) + } + } + if (bodyEditing) { + paintRenderedRotationGizmo(renderedRotationGizmo, next) + paintRenderedScene( + renderedScene, + renderAvatar( + poseFromExpression( + resolveCanvasPreviewExpression(next, activeAvatarEyes, bodyEditing, target) + ), + surfaceRef.current, + blinkValue.get(), + { + includeWire: showWireRef.current || highlightRef.current === 'head', + bodyNodes: bodyNodesRef.current, + } + ) + ) + return + } + const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) + if (avatar) setDisplayColors(resolveColors(next, avatar.colors)) + paintPose(poseFromExpression(next)) + } + + const openExpressionEditor = (index: number | null, draft: Expression) => { + suspendStateForEditor() + setBodyEditing(false) + setMode('expressions') + setEditing({ + index, + draft: { ...draft, id: index === null ? createExpressionId() : draft.id }, + }) + const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) + if (avatar) setDisplayColors(resolveColors(draft, avatar.colors)) + paintPose(poseFromExpression(draft)) + } + + const cancelExpressionEditing = () => { + setEditing(null) + const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) + if (avatar) setDisplayColors(resolveColors(expression, avatar.colors)) + paintPose(poseFromExpression(expression)) + if (!sequenceEditing) restoreStateAfterEditor() + } + + const deleteEditing = () => { + if (editing?.index === null || editing?.index === undefined) return + const next = + expressions.length <= 1 + ? [{ ...defaultExpression }] + : expressions.filter((_, index) => index !== editing.index) + const fallback = next[Math.min(editing.index, next.length - 1)] ?? defaultExpression + const deletedExpressionId = expressions[editing.index]?.id ?? editing.draft.id + const fallbackExpressionId = fallback.id + const nextSequences = remapSequencesAfterExpressionDelete( + sequences, + deletedExpressionId, + fallbackExpressionId + ) + setExpressions(next) + setSequences(nextSequences) + if (sequenceEditing) { + const draft = remapSequencesAfterExpressionDelete( + [sequenceEditing.draft], + deletedExpressionId, + fallbackExpressionId + )[0] + setSequenceEditing({ ...sequenceEditing, draft }) + } + updateStudioExpressions(next) + updateStudioSequences(nextSequences) + setActiveExpression(null) + setEditing(null) + setDeleteExpressionOpen(false) + setExpression(fallback) + const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) + if (avatar) setDisplayColors(resolveColors(fallback, avatar.colors)) + paintPose(poseFromExpression(fallback)) + if (!sequenceEditing) restoreStateAfterEditor() + } + + const openSequenceEditor = (sequence?: AvatarSequence) => { + suspendStateForEditor() + setEditing(null) + setBodyEditing(false) + setMode('states') + const draft = sequence + ? { + ...sequence, + steps: sequence.steps.map(step => ({ ...step })), + blink: { ...sequence.blink }, + } + : createSequence(expressions[activeExpression ?? 0]?.id ?? expressions[0]?.id) + setSequenceEditing({ sourceId: sequence?.id ?? null, draft }) + setSelectedSequenceStepId(draft.steps[0]?.id ?? null) + const firstStep = draft.steps[0] + const expressionIndex = firstStep + ? findExpressionIndex(expressions, firstStep.expressionId) + : -1 + const preset = expressions[expressionIndex] + if (preset) transitionToExpression(preset, expressionIndex, firstStep) + } + + const cancelSequenceEditing = () => { + if (activeState === sequenceEditing?.draft.id) stopState(false) + setSequenceEditing(null) + setSelectedSequenceStepId(null) + restoreStateAfterEditor() + } + + const saveSequenceEditing = () => { + if (!sequenceEditing?.draft.steps.length || !sequenceEditing.draft.name.trim()) return + const saved = { + ...sequenceEditing.draft, + name: sequenceEditing.draft.name.trim(), + group: sequenceEditing.draft.group.trim() || 'Custom', + } + const next = sequenceEditing.sourceId + ? sequences.map(sequence => (sequence.id === sequenceEditing.sourceId ? saved : sequence)) + : [...sequences, saved] + setSequences(next) + updateStudioSequences(next) + setSelectedState(saved.id) + if (activeState === saved.id) stopState(false) + setSequenceEditing(null) + setSelectedSequenceStepId(null) + restoreStateAfterEditor(next) + } + + const duplicateSequenceEditing = () => { + if (!sequenceEditing) return + if (activeState === sequenceEditing.draft.id) stopState(false) + const duplicate = duplicateSequence(sequenceEditing.draft) + const next = [...sequences, duplicate] + setSequences(next) + updateStudioSequences(next) + setSelectedState(duplicate.id) + setSequenceEditing({ sourceId: duplicate.id, draft: duplicate }) + setSelectedSequenceStepId(duplicate.steps[0]?.id ?? null) + } + + const duplicateState = (sequence: AvatarSequence) => { + if (activeState === sequence.id) stopState(false) + const duplicate = duplicateSequence(sequence) + const next = [...sequences, duplicate] + setSequences(next) + updateStudioSequences(next) + setSelectedState(duplicate.id) + } + + const previewStateMove = (targetId: string | null, targetGroup: string) => { + const draggedId = draggedStateId.current + if (!draggedId || draggedId === targetId) return + const current = stateDragPreview.current + const dragged = current.find(sequence => sequence.id === draggedId) + if (!dragged) return + const next = current.filter(sequence => sequence.id !== draggedId) + const moved = { ...dragged, group: targetGroup } + if (targetId) { + const targetIndex = next.findIndex(sequence => sequence.id === targetId) + next.splice(targetIndex < 0 ? next.length : targetIndex, 0, moved) + } else { + let lastGroupIndex = -1 + for (let index = next.length - 1; index >= 0; index -= 1) { + if (next[index].group !== targetGroup) continue + lastGroupIndex = index + break + } + next.splice(lastGroupIndex + 1, 0, moved) + } + stateDragPreview.current = next + setSequences(next) + } + + const commitStateMove = (targetId: string | null, targetGroup: string) => { + previewStateMove(targetId, targetGroup) + updateStudioSequences(stateDragPreview.current) + stateDragOrigin.current = null + draggedStateId.current = null + setDraggingStateId(null) + } + + const cancelStateMove = () => { + if (draggedStateId.current && stateDragOrigin.current) { + stateDragPreview.current = stateDragOrigin.current + setSequences(stateDragOrigin.current) + } + stateDragOrigin.current = null + draggedStateId.current = null + setDraggingStateId(null) + } + + const deleteSequenceEditing = () => { + if (!sequenceEditing?.sourceId) return + const next = sequences.filter(sequence => sequence.id !== sequenceEditing.sourceId) + const fallback = next[0] + setSequences(next) + updateStudioSequences(next) + if (activeState === sequenceEditing.sourceId) stopState(false) + setSelectedState(fallback?.id ?? '') + setSequenceEditing(null) + setSelectedSequenceStepId(null) + setDeleteSequenceOpen(false) + restoreStateAfterEditor(next) + } + + const selectedBodyNode = + selectedBodyNodeId === 'primary' + ? null + : (bodyNodes.find(node => node.id === selectedBodyNodeId) ?? null) + + const updateNodeVector = (property: 'position' | 'rotation', index: 0 | 1 | 2, value: number) => { + updateSelectedBodyNode(node => { + const vector = [...node[property]] as [number, number, number] + vector[index] = value + return { ...node, [property]: vector } + }) + } + const activeAvatar = avatars.find(avatar => avatar.id === activeAvatarId) ?? avatars[0] + const activeAvatarEyes = activeAvatar.eyes ?? defaultAvatarEyes + const activeSequence = sequences.find(sequence => sequence.id === activeState) ?? null + const activeSequenceLabel = activeSequence + ? activeSequence.builtIn + ? t(activeSequence.name) + : activeSequence.name + : null + const expressionById = new Map(expressions.map(item => [item.id, item])) + const exportAnimationIdSet = new Set(exportAnimationIds) + const selectedExportAnimations = sequences.filter(animation => + exportAnimationIdSet.has(animation.id) + ) + const toggleExportAnimation = (animationId: string) => { + setExportAnimationIds(current => + current.includes(animationId) + ? current.filter(id => id !== animationId) + : [...current, animationId] + ) + } + const downloadAvatarExport = () => { + if (!selectedExportAnimations.length) return + const payload = createAvatarExportPayload(activeAvatar, expressions, selectedExportAnimations) + const isReact = exportFormat === 'react' + const extension = 'zip' + const blob = isReact + ? generateReactAvatarPackage(payload) + : generateJavaScriptAvatarPackage(payload, language) + downloadBlob(blob, avatarExportFileName(activeAvatar.name, extension)) + } + const currentStudioDocument = (): StudioDocument => ({ + version: 2, + library: { activeAvatarId, avatars }, + expressions: baseBehavior.expressions, + sequences: baseBehavior.sequences, + playback: { + stateId: playbackStatus === 'stopped' ? null : (activeState ?? (selectedState || null)), + playing: statePlaying, + }, + }) + const downloadStudioProject = () => { + const blob = new Blob([serializeStudioDocument(currentStudioDocument())], { + type: 'application/json', + }) + downloadBlob(blob, 'avatar-studio-project.json') + } + const currentSnapshotSvg = () => + serializeAvatarSnapshot( + activeAvatar.name, + renderedScene, + { + body: renderedColors.body.get(), + eyes: renderedColors.eyes.get(), + }, + { + background: snapshotBackground, + colorFrom: snapshotColorFrom, + colorTo: snapshotColorTo, + size: Number(snapshotSize), + } + ) + + const downloadSnapshotSvg = () => { + downloadBlob( + new Blob([currentSnapshotSvg()], { type: 'image/svg+xml;charset=utf-8' }), + snapshotFileName(activeAvatar.name) + ) + } + const downloadSnapshotPng = () => { + const size = Number(snapshotSize) + const source = new Blob([currentSnapshotSvg()], { type: 'image/svg+xml;charset=utf-8' }) + const sourceUrl = URL.createObjectURL(source) + const image = new Image() + image.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = size + canvas.height = size + canvas.getContext('2d')?.drawImage(image, 0, 0, size, size) + URL.revokeObjectURL(sourceUrl) + canvas.toBlob(blob => { + if (blob) downloadBlob(blob, snapshotFileName(activeAvatar.name, 'png')) + }, 'image/png') + } + image.onerror = () => URL.revokeObjectURL(sourceUrl) + image.src = sourceUrl + } + const takePicture = () => { + setPhotoFlash(current => current + 1) + requestAnimationFrame(() => { + if (snapshotFormat === 'png') downloadSnapshotPng() + else downloadSnapshotSvg() + }) + } + const prepareStudioProjectImport = (file: File | undefined) => { + if (!file) return + setProjectImportError(null) + if (file.size > 10_000_000) { + setProjectImportError( + t('Ce fichier ne contient pas un projet Avatar Studio valide et compatible.') + ) + return + } + file + .text() + .then(source => { + const imported = parseImportedStudioDocument(source, currentStudioDocument()) + setPendingProjectImport({ document: imported, fileName: file.name }) + }) + .catch(() => { + setProjectImportError( + t('Ce fichier ne contient pas un projet Avatar Studio valide et compatible.') + ) + }) + } + const confirmStudioProjectImport = () => { + if (!pendingProjectImport) return + if (!persistStudioDocument(pendingProjectImport.document)) { + setProjectImportError( + t( + 'Le projet n’a pas pu être enregistré dans ce navigateur. Libère de l’espace puis réessaie.' + ) + ) + setPendingProjectImport(null) + return + } + window.location.reload() + } + const canvasExpression = editing?.draft ?? expression + const editorPageOpen = bodyEditing || editing !== null || sequenceEditing !== null + const expressionPendingDeletionId = + editing?.index !== null && editing?.index !== undefined + ? (expressions[editing.index]?.id ?? editing.draft.id) + : null + const animationsAffectedByExpressionDeletion = expressionPendingDeletionId + ? sequences.filter(sequence => + sequence.steps.some(step => step.expressionId === expressionPendingDeletionId) + ) + : [] + + useEffect(() => { + if (!editorPageOpen || focusAvatarName) return + const frame = requestAnimationFrame(() => workspaceBackButtonRef.current?.focus()) + return () => cancelAnimationFrame(frame) + }, [editorPageOpen, focusAvatarName]) + + const updateAvatarEyeDimension = (side: Side, dimension: 'width' | 'height', value: number) => { + const next = updateEyeDimension( + { ...defaultExpression, ...activeAvatarEyes }, + side, + dimension, + value, + linked[dimension] + ) + updateAvatarEyes({ + [`${dimension}Left`]: next[`${dimension}Left`], + [`${dimension}Right`]: next[`${dimension}Right`], + }) + } + const updateAvatarEyeSize = (side: Side, value: number) => { + const next = scaleEye({ ...defaultExpression, ...activeAvatarEyes }, side, value, linked.size) + updateAvatarEyes({ + widthLeft: next.widthLeft, + widthRight: next.widthRight, + heightLeft: next.heightLeft, + heightRight: next.heightRight, + }) + } + const updateAvatarEyePosition = (side: Side, axis: 'X' | 'Y', value: number) => { + const next = updateEyePosition( + { ...defaultExpression, ...activeAvatarEyes }, + side, + axis, + value, + linked.position + ) + updateAvatarEyes({ + [`position${axis}Left`]: next[`position${axis}Left`], + [`position${axis}Right`]: next[`position${axis}Right`], + }) + } + const persistEditedEyeExpression = (next: Expression) => { + updateAvatarEyes({ + widthLeft: next.widthLeft, + widthRight: next.widthRight, + heightLeft: next.heightLeft, + heightRight: next.heightRight, + spacing: next.spacing, + positionXLeft: next.positionXLeft, + positionXRight: next.positionXRight, + positionYLeft: next.positionYLeft, + positionYRight: next.positionYRight, + leftAngle: next.leftAngle, + rightAngle: next.rightAngle, + }) + } + return { + activateAvatar, + activeAvatar, + activeAvatarEyes, + activeAvatarId, + activeExpression, + activeSequence, + activeSequenceLabel, + activeState, + addBodyNode, + animationsAffectedByExpressionDeletion, + avatarDragOrigin, + avatarDragPreview, + avatars, + avatarsRef, + blink, + bodyEditing, + bodyNodes, + cancelAvatarEditing, + cancelAvatarMove, + cancelExpressionEditing, + cancelExpressionMove, + cancelSequenceEditing, + cancelStateMove, + canvasExpression, + commitAvatarMove, + commitBodyNode, + commitExpressionMove, + commitStateMove, + confirmStudioProjectImport, + createNewAvatar, + deleteActiveAvatar, + deleteAvatarOpen, + deleteEditing, + deleteExpressionOpen, + deleteSelectedBodyNode, + deleteSequenceEditing, + deleteSequenceOpen, + downloadAvatarExport, + downloadStudioProject, + draggedAvatarId, + draggedExpressionId, + draggedStateId, + draggingAvatarId, + draggingExpressionId, + draggingStateId, + duplicateAvatar, + duplicateExpression, + duplicateSelectedBodyNode, + duplicateSequenceEditing, + duplicateState, + editing, + editorPageOpen, + exportAnimationIdSet, + exportFormat, + expression, + expressionById, + expressionDragOrigin, + expressionDragPreview, + expressions, + focusAvatarName, + freezeLivePreviewForManipulation, + highlight, + language, + launchSequence, + linked, + mode, + openExpressionEditor, + openSequenceEditor, + pauseState, + pendingProjectImport, + persistEditedEyeExpression, + photoFlash, + playbackStatus, + playbackVisual, + prepareStudioProjectImport, + previewAvatarMove, + previewCanvasExpression, + previewExpressionDraft, + previewExpressionMove, + previewSelectedBodyNode, + previewStateMove, + projectImportError, + projectImportRef, + reduceMotion, + renameActiveAvatar, + renderedColors, + renderedRotationGizmo, + renderedScene, + saveAvatarEditing, + saveEditing, + saveSequenceEditing, + selectBodyNode, + selectedBodyNode, + selectedBodyNodeId, + selectedExportAnimations, + selectedEyeSide, + selectedSequenceStepId, + selectedState, + sequenceEditing, + sequences, + setDeleteAvatarOpen, + setDeleteExpressionOpen, + setDeleteSequenceOpen, + setDraggingAvatarId, + setDraggingExpressionId, + setDraggingStateId, + setEditing, + setExportAnimationIds, + setExportFormat, + setFocusAvatarName, + setLanguage, + setLinked, + setMode, + setPendingProjectImport, + setSelectedEyeSide, + setSelectedSequenceStepId, + setSequenceEditing, + setSnapshotBackground, + setSnapshotColorFrom, + setSnapshotColorTo, + setSnapshotFormat, + setSnapshotSize, + setSpringSpeed, + setStatePlayerExpanded, + showWire, + snapshotBackground, + snapshotColorFrom, + snapshotColorTo, + snapshotFormat, + snapshotSize, + springSpeed, + springSpeedRef, + stateDragOrigin, + stateDragPreview, + statePlayerExpanded, + statePlaying, + stopState, + surface, + t, + takePicture, + toggleExportAnimation, + toggleStatePlayback, + transitionToExpression, + updateAvatarColors, + updateAvatarEyeDimension, + updateAvatarEyePosition, + updateAvatarEyeSize, + updateAvatarEyes, + updateDimension, + updateHighlight, + updateImmediate, + updateNodeVector, + updateSelectedBodyNode, + updateSize, + updateSpacing, + updateSurface, + updateWireVisibility, + workspaceBackButtonRef, + } +} + +export type StudioController = ReturnType diff --git a/src/__tests__/i18n-test.ts b/src/i18n/__tests__/i18n-test.ts similarity index 97% rename from src/__tests__/i18n-test.ts rename to src/i18n/__tests__/i18n-test.ts index f68e0cf..e64a8c3 100644 --- a/src/__tests__/i18n-test.ts +++ b/src/i18n/__tests__/i18n-test.ts @@ -1,4 +1,4 @@ -import { translateStudioText } from '../i18n' +import { translateStudioText } from '@/i18n' describe('avatar studio translations', () => { it('uses English for static interface copy', () => { diff --git a/src/i18n.ts b/src/i18n/index.ts similarity index 99% rename from src/i18n.ts rename to src/i18n/index.ts index 7abab67..2fd0e12 100644 --- a/src/i18n.ts +++ b/src/i18n/index.ts @@ -6,7 +6,7 @@ import { useState, type ReactNode, } from 'react' -import { chinese, translateChineseDynamicText } from './i18n.zh' +import { chinese, translateChineseDynamicText } from './zh' export type StudioLanguage = 'en' | 'fr' | 'zh-CN' diff --git a/src/i18n.zh.ts b/src/i18n/zh.ts similarity index 100% rename from src/i18n.zh.ts rename to src/i18n/zh.ts diff --git a/src/main.tsx b/src/main.tsx index 5bae583..c82f987 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,8 +1,8 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import App from './App' -import './styles.css' +import App from './app/App' +import './app/styles.css' createRoot(document.getElementById('root')!).render( diff --git a/vite.config.ts b/vite.config.ts index 153172a..b8015e9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,6 +10,7 @@ const root = path.dirname(fileURLToPath(import.meta.url)) export default defineConfig({ root, + base: './', plugins: [react(), babel({ presets: [reactCompilerPreset()] }), tailwindcss()], resolve: { alias: { diff --git a/vitest.config.ts b/vitest.config.ts index e505796..799596f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,19 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' + import { defineConfig } from 'vitest/config' +const root = path.dirname(fileURLToPath(import.meta.url)) + export default defineConfig({ + resolve: { + alias: { + '@': path.join(root, 'src'), + }, + }, test: { environment: 'node', globals: true, - include: ['src/__tests__/**/*-test.ts'], + include: ['src/**/__tests__/**/*-test.ts'], }, }) diff --git a/yarn.lock b/yarn.lock deleted file mode 100644 index c90e4f3..0000000 --- a/yarn.lock +++ /dev/null @@ -1,1992 +0,0 @@ -# This file is generated by running "yarn install" inside your project. -# Manual changes might be lost - proceed with caution! - -__metadata: - version: 8 - cacheKey: 10c0 - -"@babel/code-frame@npm:^7.29.7": - version: 7.29.7 - resolution: "@babel/code-frame@npm:7.29.7" - dependencies: - "@babel/helper-validator-identifier": "npm:^7.29.7" - js-tokens: "npm:^4.0.0" - picocolors: "npm:^1.1.1" - checksum: 10c0/169fc2080169a40c1760155eaaaf739bcb882df0bec76a83adbda5493645bc17270a3434b8848c494b1933e96fe1d147370001e3cda09a39f43ae30f08ef2069 - languageName: node - linkType: hard - -"@babel/compat-data@npm:^7.29.7": - version: 7.29.7 - resolution: "@babel/compat-data@npm:7.29.7" - checksum: 10c0/47913f05e08a45a1c9df38c02b4b49e391005085b489432647a1abe112e5d9c75e3be8ea5972b7f6da4ec5d1339922ceb9ea02b8a25d4ed1cb8636e5261f344e - languageName: node - linkType: hard - -"@babel/core@npm:^7.29.0": - version: 7.29.7 - resolution: "@babel/core@npm:7.29.7" - dependencies: - "@babel/code-frame": "npm:^7.29.7" - "@babel/generator": "npm:^7.29.7" - "@babel/helper-compilation-targets": "npm:^7.29.7" - "@babel/helper-module-transforms": "npm:^7.29.7" - "@babel/helpers": "npm:^7.29.7" - "@babel/parser": "npm:^7.29.7" - "@babel/template": "npm:^7.29.7" - "@babel/traverse": "npm:^7.29.7" - "@babel/types": "npm:^7.29.7" - "@jridgewell/remapping": "npm:^2.3.5" - convert-source-map: "npm:^2.0.0" - debug: "npm:^4.1.0" - gensync: "npm:^1.0.0-beta.2" - json5: "npm:^2.2.3" - semver: "npm:^6.3.1" - checksum: 10c0/112fb09c24de7a1de64d1de2c31fe65c4e6af4cb2fb6e6d99ea5373e6fc51e75b88581c0efae4c4c68f119a02a988c7106e95011a41530a2fb8ed793c7eaa07b - languageName: node - linkType: hard - -"@babel/generator@npm:^7.29.7, @babel/generator@npm:^7.29.8": - version: 7.29.8 - resolution: "@babel/generator@npm:7.29.8" - dependencies: - "@babel/parser": "npm:^7.29.8" - "@babel/types": "npm:^7.29.8" - "@jridgewell/gen-mapping": "npm:^0.3.12" - "@jridgewell/trace-mapping": "npm:^0.3.28" - jsesc: "npm:^3.0.2" - checksum: 10c0/7b896696314a659652393b76d78276e236acd0f7fae40a9a1af7f01c76aeafc630dd0966aad6f9386d35d7c674a8e6e2d8e217c44d25fb11460e68afa9ba8441 - languageName: node - linkType: hard - -"@babel/helper-compilation-targets@npm:^7.29.7": - version: 7.29.7 - resolution: "@babel/helper-compilation-targets@npm:7.29.7" - dependencies: - "@babel/compat-data": "npm:^7.29.7" - "@babel/helper-validator-option": "npm:^7.29.7" - browserslist: "npm:^4.24.0" - lru-cache: "npm:^5.1.1" - semver: "npm:^6.3.1" - checksum: 10c0/4c15fd4c69a0a7047799a28a88460c19cede0a0ee8af994ea169114986f4af48b92c7393a4a3fee0456c11a656eece3448a6ed06354453d6c27cccf17195453b - languageName: node - linkType: hard - -"@babel/helper-globals@npm:^7.29.7": - version: 7.29.7 - resolution: "@babel/helper-globals@npm:7.29.7" - checksum: 10c0/f38417c40b1129a1b2b519ca961b9040c8827d1444fd74068702286b91b77089431dc76b6b9d5c1496e5da2a4f3ad329c6946e688ba3fa0d1d0b3d2b4f34f36a - languageName: node - linkType: hard - -"@babel/helper-module-imports@npm:^7.29.7": - version: 7.29.7 - resolution: "@babel/helper-module-imports@npm:7.29.7" - dependencies: - "@babel/traverse": "npm:^7.29.7" - "@babel/types": "npm:^7.29.7" - checksum: 10c0/6adf60d97356027413342a092f818d9678c4f5caff716a33e3284b5ae14e47a9e88059d421dde4ee4894691260039a12602c0e7becadc175602194b40dfa345d - languageName: node - linkType: hard - -"@babel/helper-module-transforms@npm:^7.29.7": - version: 7.29.7 - resolution: "@babel/helper-module-transforms@npm:7.29.7" - dependencies: - "@babel/helper-module-imports": "npm:^7.29.7" - "@babel/helper-validator-identifier": "npm:^7.29.7" - "@babel/traverse": "npm:^7.29.7" - peerDependencies: - "@babel/core": ^7.0.0 - checksum: 10c0/ee5a2172c24a42be696836f4b0d947489c9729d8adf5821885cf77d1ad5333e3c447368e9a71f67df1099570490553dccf9f888ef0a92a48aa63cb086bd8c7e1 - languageName: node - linkType: hard - -"@babel/helper-string-parser@npm:^7.29.7": - version: 7.29.7 - resolution: "@babel/helper-string-parser@npm:7.29.7" - checksum: 10c0/194bc0f1716e396d5ffde56ad6119745fb9557662c98611590e5e454906783a4ccb21ce93056b8eb69a4909044834e45d96e50ac695bbe9e3221648fe033c06c - languageName: node - linkType: hard - -"@babel/helper-validator-identifier@npm:^7.29.7": - version: 7.29.7 - resolution: "@babel/helper-validator-identifier@npm:7.29.7" - checksum: 10c0/4795354e7ae0dcafa72de1cd04ec51252dc1498517170beaf019e03effc5b7bf13c6b21a3949a77e07b8125be7f106ed1131350d8ebd4566ae874094a726d62b - languageName: node - linkType: hard - -"@babel/helper-validator-option@npm:^7.29.7": - version: 7.29.7 - resolution: "@babel/helper-validator-option@npm:7.29.7" - checksum: 10c0/d2a06c6d0ac40ba4a2f219fc2cab249c7a94bacdb2686273b7f9598571c908809b48468ff588915a346e6cc7296f60b581023d1d498b747fed06f779d335c2cc - languageName: node - linkType: hard - -"@babel/helpers@npm:^7.29.7": - version: 7.29.7 - resolution: "@babel/helpers@npm:7.29.7" - dependencies: - "@babel/template": "npm:^7.29.7" - "@babel/types": "npm:^7.29.7" - checksum: 10c0/218e8d10953647c9f44775f5a022b227a182674853b5ea8631889deb7e1a3e4bc870388aaecf59bb8bd92a87f9a96220ed3f70a35bffec6bcf9169ecb67891ac - languageName: node - linkType: hard - -"@babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.7, @babel/parser@npm:^7.29.8": - version: 7.29.8 - resolution: "@babel/parser@npm:7.29.8" - dependencies: - "@babel/types": "npm:^7.29.8" - bin: - parser: ./bin/babel-parser.js - checksum: 10c0/acc890c5e6a6dd40863a47b50bac111d7185ee6fbbe163ebe11d5214854ca2adb901462ad4d718a65090ef84bd2230e9e8ab45a2e0caccc685f1f57ab0bb1e28 - languageName: node - linkType: hard - -"@babel/runtime@npm:^7.29.2": - version: 7.29.7 - resolution: "@babel/runtime@npm:7.29.7" - checksum: 10c0/ca11572f7146b21e0bde6a9ed4bb6a89eafbee5f0944c7eb54d0d8a2dac962c33638a1d611e14faa71dfbb92b4b5f9236232208568a6b7d5c6f3f39ddb91771e - languageName: node - linkType: hard - -"@babel/template@npm:^7.29.7": - version: 7.29.7 - resolution: "@babel/template@npm:7.29.7" - dependencies: - "@babel/code-frame": "npm:^7.29.7" - "@babel/parser": "npm:^7.29.7" - "@babel/types": "npm:^7.29.7" - checksum: 10c0/8bb7f900dcab0e9e1c5ffbc33ca10e0d26b7b2e2ca804becb73ee771b9c4ed6e2908a4ae4a14c08560febb45d2b6b9a173955e42ad404d05f8b04840a14d9c58 - languageName: node - linkType: hard - -"@babel/traverse@npm:^7.29.7": - version: 7.29.8 - resolution: "@babel/traverse@npm:7.29.8" - dependencies: - "@babel/code-frame": "npm:^7.29.7" - "@babel/generator": "npm:^7.29.8" - "@babel/helper-globals": "npm:^7.29.7" - "@babel/parser": "npm:^7.29.8" - "@babel/template": "npm:^7.29.7" - "@babel/types": "npm:^7.29.8" - debug: "npm:^4.3.1" - checksum: 10c0/87a28989c434add26d787776ac6d30f749b89cb030f2a605c89f671a516a6fa165ac0476f07e2b69feed70128b2734cae1cbbf41dfe95ccf22081bd8f8b91923 - languageName: node - linkType: hard - -"@babel/types@npm:^7.26.0, @babel/types@npm:^7.29.7, @babel/types@npm:^7.29.8": - version: 7.29.8 - resolution: "@babel/types@npm:7.29.8" - dependencies: - "@babel/helper-string-parser": "npm:^7.29.7" - "@babel/helper-validator-identifier": "npm:^7.29.7" - checksum: 10c0/be7c279f0abf2a086c633e21b49c7ca80275d05283cc5a268b67a708c9914bd0c944f1422b3eb3cb37682a2af5d560abf520ccf9b01b53ecbfe6b71fbc3fdde6 - languageName: node - linkType: hard - -"@base-ui/react@npm:^1.7.0": - version: 1.7.0 - resolution: "@base-ui/react@npm:1.7.0" - dependencies: - "@babel/runtime": "npm:^7.29.2" - "@base-ui/utils": "npm:0.3.2" - "@floating-ui/react-dom": "npm:^2.1.9" - "@floating-ui/utils": "npm:^0.2.12" - use-sync-external-store: "npm:^1.6.0" - peerDependencies: - "@date-fns/tz": ^1.2.0 - "@types/react": ^17 || ^18 || ^19 - date-fns: ^4.0.0 - react: ^17 || ^18 || ^19 - react-dom: ^17 || ^18 || ^19 - peerDependenciesMeta: - "@date-fns/tz": - optional: true - "@types/react": - optional: true - date-fns: - optional: true - checksum: 10c0/05bc69f68431ff30a5fac98d8e75c77af7c27749d10aeac10ab7f26843a0a8b49ca5cff32654aad1a107cb7f44aeb1705c15138c9c7f7ddc19d6321fbb263e4d - languageName: node - linkType: hard - -"@base-ui/utils@npm:0.3.2": - version: 0.3.2 - resolution: "@base-ui/utils@npm:0.3.2" - dependencies: - "@babel/runtime": "npm:^7.29.2" - "@floating-ui/utils": "npm:^0.2.12" - reselect: "npm:^5.2.0" - use-sync-external-store: "npm:^1.6.0" - peerDependencies: - "@types/react": ^17 || ^18 || ^19 - react: ^17 || ^18 || ^19 - react-dom: ^17 || ^18 || ^19 - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10c0/b523339f350679915550605e59754b5db9806a41a3b390d6f6adad29fedc0caf084c8ad1bad6bb62a463360593382defb7d3ea941a94404ed9db0142fdae5800 - languageName: node - linkType: hard - -"@emnapi/core@npm:^1.11.1": - version: 1.11.3 - resolution: "@emnapi/core@npm:1.11.3" - dependencies: - "@emnapi/wasi-threads": "npm:1.2.3" - tslib: "npm:^2.4.0" - checksum: 10c0/4ca08d349a82d5d2887ccc9e12df630877b0412ddcd59b9faee61e3c3947ccead27a18257a18bfe17abdf2b0709857808ad75d423ac49edd50c32fb140a7ed6e - languageName: node - linkType: hard - -"@emnapi/runtime@npm:^1.11.1": - version: 1.11.3 - resolution: "@emnapi/runtime@npm:1.11.3" - dependencies: - tslib: "npm:^2.4.0" - checksum: 10c0/a00f1020fefb9d4145c367f93a9fddb383a00da8ffd7871e20b19659890379b83aeecb9f84d7d0eda5456343f4a09eb05b0acb5153b0d3d889539dedb1ed87c3 - languageName: node - linkType: hard - -"@emnapi/wasi-threads@npm:1.2.3, @emnapi/wasi-threads@npm:^1.2.2": - version: 1.2.3 - resolution: "@emnapi/wasi-threads@npm:1.2.3" - dependencies: - tslib: "npm:^2.4.0" - checksum: 10c0/5aed84bc5dbe867973b20406ca1ed95661a4892ecaef80378fdf2d37424b3f7b9c428df8a3e1d82b783a3345a530032bf049e699e1f113ea52203c3e9b4e6ce5 - languageName: node - linkType: hard - -"@floating-ui/core@npm:^1.8.0": - version: 1.8.0 - resolution: "@floating-ui/core@npm:1.8.0" - dependencies: - "@floating-ui/utils": "npm:^0.2.12" - checksum: 10c0/cece958f6fab0f8cd7740cd57988c4f8bc5356dca39dd4c093433d44c91952c3a02c7a54d93d7ed6c078b6544d531a29f66621d2839c8bfbb4467dca6aed7df0 - languageName: node - linkType: hard - -"@floating-ui/dom@npm:^1.8.0": - version: 1.8.0 - resolution: "@floating-ui/dom@npm:1.8.0" - dependencies: - "@floating-ui/core": "npm:^1.8.0" - "@floating-ui/utils": "npm:^0.2.12" - checksum: 10c0/c32b2de7a596b69cfffa909c7028825e14f6319be1b45da529f8943c89fe16b75a49c214e800c87ded3f172353d3b99163d50072f11410488e756961b7b629cc - languageName: node - linkType: hard - -"@floating-ui/react-dom@npm:^2.1.9": - version: 2.1.9 - resolution: "@floating-ui/react-dom@npm:2.1.9" - dependencies: - "@floating-ui/dom": "npm:^1.8.0" - peerDependencies: - react: ">=16.8.0" - react-dom: ">=16.8.0" - checksum: 10c0/9fa9851069738cb9591a1ab2da16e257f193eae5fd2d6d48ad0b1e73f3c049929b6805c5ad43d4af3c1da63c60b215b0ee4651a1fe5caeb80e66d9d423744348 - languageName: node - linkType: hard - -"@floating-ui/utils@npm:^0.2.12": - version: 0.2.12 - resolution: "@floating-ui/utils@npm:0.2.12" - checksum: 10c0/bb910b90d56991a62011afaf5f8e0c4b7fb6dab69403f4dd17fbd6eb25560b28d533dff9791f270b4f459852e9006a1077b5d73cbedb5e34d9424afc6a828314 - languageName: node - linkType: hard - -"@isaacs/fs-minipass@npm:^4.0.0": - version: 4.0.1 - resolution: "@isaacs/fs-minipass@npm:4.0.1" - dependencies: - minipass: "npm:^7.0.4" - checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2 - languageName: node - linkType: hard - -"@jridgewell/gen-mapping@npm:^0.3.12, @jridgewell/gen-mapping@npm:^0.3.5": - version: 0.3.13 - resolution: "@jridgewell/gen-mapping@npm:0.3.13" - dependencies: - "@jridgewell/sourcemap-codec": "npm:^1.5.0" - "@jridgewell/trace-mapping": "npm:^0.3.24" - checksum: 10c0/9a7d65fb13bd9aec1fbab74cda08496839b7e2ceb31f5ab922b323e94d7c481ce0fc4fd7e12e2610915ed8af51178bdc61e168e92a8c8b8303b030b03489b13b - languageName: node - linkType: hard - -"@jridgewell/remapping@npm:^2.3.5": - version: 2.3.5 - resolution: "@jridgewell/remapping@npm:2.3.5" - dependencies: - "@jridgewell/gen-mapping": "npm:^0.3.5" - "@jridgewell/trace-mapping": "npm:^0.3.24" - checksum: 10c0/3de494219ffeb2c5c38711d0d7bb128097edf91893090a2dbc8ee0b55d092bb7347b1fd0f478486c5eab010e855c73927b1666f2107516d472d24a73017d1194 - languageName: node - linkType: hard - -"@jridgewell/resolve-uri@npm:^3.1.0": - version: 3.1.2 - resolution: "@jridgewell/resolve-uri@npm:3.1.2" - checksum: 10c0/d502e6fb516b35032331406d4e962c21fe77cdf1cbdb49c6142bcbd9e30507094b18972778a6e27cbad756209cfe34b1a27729e6fa08a2eb92b33943f680cf1e - languageName: node - linkType: hard - -"@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0, @jridgewell/sourcemap-codec@npm:^1.5.5": - version: 1.5.5 - resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" - checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0 - languageName: node - linkType: hard - -"@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.28": - version: 0.3.31 - resolution: "@jridgewell/trace-mapping@npm:0.3.31" - dependencies: - "@jridgewell/resolve-uri": "npm:^3.1.0" - "@jridgewell/sourcemap-codec": "npm:^1.4.14" - checksum: 10c0/4b30ec8cd56c5fd9a661f088230af01e0c1a3888d11ffb6b47639700f71225be21d1f7e168048d6d4f9449207b978a235c07c8f15c07705685d16dc06280e9d9 - languageName: node - linkType: hard - -"@napi-rs/wasm-runtime@npm:^1.1.4": - version: 1.2.3 - resolution: "@napi-rs/wasm-runtime@npm:1.2.3" - dependencies: - "@tybys/wasm-util": "npm:^0.10.3" - peerDependencies: - "@emnapi/core": ^1.7.1 || ^2.0.0-alpha.4 - "@emnapi/runtime": ^1.7.1 || ^2.0.0-alpha.4 - checksum: 10c0/6da0a4bf9df79e9abfb3e3d462f6a5d42889be39426040038c9dd5925865585d7dbd06dd439c2ffc20f49ef2751412da5bd748cfb3c5b049142d72c0550f6f79 - languageName: node - linkType: hard - -"@oxc-project/types@npm:=0.144.0": - version: 0.144.0 - resolution: "@oxc-project/types@npm:0.144.0" - checksum: 10c0/997c6c33f09706af604ece0e99c698965757887aca4b42c5fb5ddcb11c39876c1e824b188b1e6582276355468a9b13f955d3b822d3b532cd6bedbeb64b603ff0 - languageName: node - linkType: hard - -"@rolldown/binding-android-arm64@npm:1.2.4": - version: 1.2.4 - resolution: "@rolldown/binding-android-arm64@npm:1.2.4" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - -"@rolldown/binding-darwin-arm64@npm:1.2.4": - version: 1.2.4 - resolution: "@rolldown/binding-darwin-arm64@npm:1.2.4" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"@rolldown/binding-darwin-x64@npm:1.2.4": - version: 1.2.4 - resolution: "@rolldown/binding-darwin-x64@npm:1.2.4" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"@rolldown/binding-freebsd-x64@npm:1.2.4": - version: 1.2.4 - resolution: "@rolldown/binding-freebsd-x64@npm:1.2.4" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - -"@rolldown/binding-linux-arm-gnueabihf@npm:1.2.4": - version: 1.2.4 - resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.2.4" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - -"@rolldown/binding-linux-arm64-gnu@npm:1.2.4": - version: 1.2.4 - resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.2.4" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - -"@rolldown/binding-linux-arm64-musl@npm:1.2.4": - version: 1.2.4 - resolution: "@rolldown/binding-linux-arm64-musl@npm:1.2.4" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - -"@rolldown/binding-linux-ppc64-gnu@npm:1.2.4": - version: 1.2.4 - resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.2.4" - conditions: os=linux & cpu=ppc64 & libc=glibc - languageName: node - linkType: hard - -"@rolldown/binding-linux-s390x-gnu@npm:1.2.4": - version: 1.2.4 - resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.2.4" - conditions: os=linux & cpu=s390x & libc=glibc - languageName: node - linkType: hard - -"@rolldown/binding-linux-x64-gnu@npm:1.2.4": - version: 1.2.4 - resolution: "@rolldown/binding-linux-x64-gnu@npm:1.2.4" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - -"@rolldown/binding-linux-x64-musl@npm:1.2.4": - version: 1.2.4 - resolution: "@rolldown/binding-linux-x64-musl@npm:1.2.4" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - -"@rolldown/binding-openharmony-arm64@npm:1.2.4": - version: 1.2.4 - resolution: "@rolldown/binding-openharmony-arm64@npm:1.2.4" - conditions: os=openharmony & cpu=arm64 - languageName: node - linkType: hard - -"@rolldown/binding-win32-arm64-msvc@npm:1.2.4": - version: 1.2.4 - resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.2.4" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - -"@rolldown/binding-win32-x64-msvc@npm:1.2.4": - version: 1.2.4 - resolution: "@rolldown/binding-win32-x64-msvc@npm:1.2.4" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - -"@rolldown/plugin-babel@npm:^0.2.3": - version: 0.2.3 - resolution: "@rolldown/plugin-babel@npm:0.2.3" - dependencies: - picomatch: "npm:^4.0.4" - peerDependencies: - "@babel/core": ^7.29.0 || ^8.0.0-rc.1 - "@babel/plugin-transform-runtime": ^7.29.0 || ^8.0.0-rc.1 - "@babel/runtime": ^7.27.0 || ^8.0.0-rc.1 - rolldown: ^1.0.0-rc.5 - vite: ^8.0.0 - peerDependenciesMeta: - "@babel/plugin-transform-runtime": - optional: true - "@babel/runtime": - optional: true - vite: - optional: true - checksum: 10c0/84afe9854137b576f963f4fd11080dc0b8afe6243d565513d33ddfed9ea9149652110def881a3911b5e889f5fa505df036c6839f4d0fd01d4222297d7f2baa21 - languageName: node - linkType: hard - -"@rolldown/pluginutils@npm:^1.0.0, @rolldown/pluginutils@npm:^1.0.1": - version: 1.0.1 - resolution: "@rolldown/pluginutils@npm:1.0.1" - checksum: 10c0/99d9b06d90196823e4d8c841f258db7a16e5dbba5824a2962b05d907b79f1ba929d56f22dd744fd530936e568c865ee56a719dc31e57e13bc0a8eb4764a8d8dd - languageName: node - linkType: hard - -"@standard-schema/spec@npm:^1.1.0": - version: 1.1.0 - resolution: "@standard-schema/spec@npm:1.1.0" - checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526 - languageName: node - linkType: hard - -"@tailwindcss/node@npm:4.3.3": - version: 4.3.3 - resolution: "@tailwindcss/node@npm:4.3.3" - dependencies: - "@jridgewell/remapping": "npm:^2.3.5" - enhanced-resolve: "npm:^5.24.1" - jiti: "npm:^2.7.0" - lightningcss: "npm:1.32.0" - magic-string: "npm:^0.30.21" - source-map-js: "npm:^1.2.1" - tailwindcss: "npm:4.3.3" - checksum: 10c0/0123669be5b3e78d42b0e10cb34d547fb8574c8e7dc7a97a141c8a4ab4215852b5b1b99b97763e898e1da1493ee8424cb72e28225cc4876828c95156f94d7f0e - languageName: node - linkType: hard - -"@tailwindcss/oxide-android-arm64@npm:4.3.3": - version: 4.3.3 - resolution: "@tailwindcss/oxide-android-arm64@npm:4.3.3" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - -"@tailwindcss/oxide-darwin-arm64@npm:4.3.3": - version: 4.3.3 - resolution: "@tailwindcss/oxide-darwin-arm64@npm:4.3.3" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"@tailwindcss/oxide-darwin-x64@npm:4.3.3": - version: 4.3.3 - resolution: "@tailwindcss/oxide-darwin-x64@npm:4.3.3" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"@tailwindcss/oxide-freebsd-x64@npm:4.3.3": - version: 4.3.3 - resolution: "@tailwindcss/oxide-freebsd-x64@npm:4.3.3" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - -"@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.3.3": - version: 4.3.3 - resolution: "@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.3.3" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - -"@tailwindcss/oxide-linux-arm64-gnu@npm:4.3.3": - version: 4.3.3 - resolution: "@tailwindcss/oxide-linux-arm64-gnu@npm:4.3.3" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - -"@tailwindcss/oxide-linux-arm64-musl@npm:4.3.3": - version: 4.3.3 - resolution: "@tailwindcss/oxide-linux-arm64-musl@npm:4.3.3" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - -"@tailwindcss/oxide-linux-x64-gnu@npm:4.3.3": - version: 4.3.3 - resolution: "@tailwindcss/oxide-linux-x64-gnu@npm:4.3.3" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - -"@tailwindcss/oxide-linux-x64-musl@npm:4.3.3": - version: 4.3.3 - resolution: "@tailwindcss/oxide-linux-x64-musl@npm:4.3.3" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - -"@tailwindcss/oxide-wasm32-wasi@npm:4.3.3": - version: 4.3.3 - resolution: "@tailwindcss/oxide-wasm32-wasi@npm:4.3.3" - dependencies: - "@emnapi/core": "npm:^1.11.1" - "@emnapi/runtime": "npm:^1.11.1" - "@emnapi/wasi-threads": "npm:^1.2.2" - "@napi-rs/wasm-runtime": "npm:^1.1.4" - "@tybys/wasm-util": "npm:^0.10.2" - tslib: "npm:^2.8.1" - conditions: cpu=wasm32 - languageName: node - linkType: hard - -"@tailwindcss/oxide-win32-arm64-msvc@npm:4.3.3": - version: 4.3.3 - resolution: "@tailwindcss/oxide-win32-arm64-msvc@npm:4.3.3" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - -"@tailwindcss/oxide-win32-x64-msvc@npm:4.3.3": - version: 4.3.3 - resolution: "@tailwindcss/oxide-win32-x64-msvc@npm:4.3.3" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - -"@tailwindcss/oxide@npm:4.3.3": - version: 4.3.3 - resolution: "@tailwindcss/oxide@npm:4.3.3" - dependencies: - "@tailwindcss/oxide-android-arm64": "npm:4.3.3" - "@tailwindcss/oxide-darwin-arm64": "npm:4.3.3" - "@tailwindcss/oxide-darwin-x64": "npm:4.3.3" - "@tailwindcss/oxide-freebsd-x64": "npm:4.3.3" - "@tailwindcss/oxide-linux-arm-gnueabihf": "npm:4.3.3" - "@tailwindcss/oxide-linux-arm64-gnu": "npm:4.3.3" - "@tailwindcss/oxide-linux-arm64-musl": "npm:4.3.3" - "@tailwindcss/oxide-linux-x64-gnu": "npm:4.3.3" - "@tailwindcss/oxide-linux-x64-musl": "npm:4.3.3" - "@tailwindcss/oxide-wasm32-wasi": "npm:4.3.3" - "@tailwindcss/oxide-win32-arm64-msvc": "npm:4.3.3" - "@tailwindcss/oxide-win32-x64-msvc": "npm:4.3.3" - dependenciesMeta: - "@tailwindcss/oxide-android-arm64": - optional: true - "@tailwindcss/oxide-darwin-arm64": - optional: true - "@tailwindcss/oxide-darwin-x64": - optional: true - "@tailwindcss/oxide-freebsd-x64": - optional: true - "@tailwindcss/oxide-linux-arm-gnueabihf": - optional: true - "@tailwindcss/oxide-linux-arm64-gnu": - optional: true - "@tailwindcss/oxide-linux-arm64-musl": - optional: true - "@tailwindcss/oxide-linux-x64-gnu": - optional: true - "@tailwindcss/oxide-linux-x64-musl": - optional: true - "@tailwindcss/oxide-wasm32-wasi": - optional: true - "@tailwindcss/oxide-win32-arm64-msvc": - optional: true - "@tailwindcss/oxide-win32-x64-msvc": - optional: true - checksum: 10c0/6c38b25226dad252347451ed8953a998432b51f1e068b25e021215b886ccf8bcf3af58faf5f4ab50368091e657ca2fa341df1deca6101651eddb567122f2acb5 - languageName: node - linkType: hard - -"@tailwindcss/vite@npm:^4.3.3": - version: 4.3.3 - resolution: "@tailwindcss/vite@npm:4.3.3" - dependencies: - "@tailwindcss/node": "npm:4.3.3" - "@tailwindcss/oxide": "npm:4.3.3" - tailwindcss: "npm:4.3.3" - peerDependencies: - vite: ^5.2.0 || ^6 || ^7 || ^8 - checksum: 10c0/bb1fe12f7a0bb8f6d6142f6bb2e96060ee853902314ef838e8d7f50cf30cc4b07adb7c2c86be2164e0d62eb581b8cc4df2d9aa13b4449f597a67f73c5772d549 - languageName: node - linkType: hard - -"@tybys/wasm-util@npm:^0.10.2, @tybys/wasm-util@npm:^0.10.3": - version: 0.10.3 - resolution: "@tybys/wasm-util@npm:0.10.3" - dependencies: - tslib: "npm:^2.4.0" - checksum: 10c0/fd2bd2a79c6cd8c79ed1cf7a0fa375c64589264c88a27acaf9756d556b453ea222b62a4f68dd2fbb8b3a78b6bab3b1f4fb2431b6afc6aeda8344b53a521a1cd3 - languageName: node - linkType: hard - -"@types/chai@npm:^5.2.2": - version: 5.2.3 - resolution: "@types/chai@npm:5.2.3" - dependencies: - "@types/deep-eql": "npm:*" - assertion-error: "npm:^2.0.1" - checksum: 10c0/e0ef1de3b6f8045a5e473e867c8565788c444271409d155588504840ad1a53611011f85072188c2833941189400228c1745d78323dac13fcede9c2b28bacfb2f - languageName: node - linkType: hard - -"@types/deep-eql@npm:*": - version: 4.0.2 - resolution: "@types/deep-eql@npm:4.0.2" - checksum: 10c0/bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844 - languageName: node - linkType: hard - -"@types/estree@npm:^1.0.0": - version: 1.0.9 - resolution: "@types/estree@npm:1.0.9" - checksum: 10c0/3ad3286ca2988cd550dafb8f2ad599c8474868e954fa601a36655bdfefd8039f7c714b8c1c7f2ae219ffbd58bd4660e66fa7479a0120fc02d4777057d4865387 - languageName: node - linkType: hard - -"@types/node@npm:^24.10.1": - version: 24.13.3 - resolution: "@types/node@npm:24.13.3" - dependencies: - undici-types: "npm:~7.18.0" - checksum: 10c0/a5bc08f49b9581dcdca90e02cd77197a3c799840807664942e5cd5161a553d4d2ae8064f7e3294410d748d7d098b5c1848be7fa50c06f29c4193ab16be4e59eb - languageName: node - linkType: hard - -"@types/react-dom@npm:^19.2.3": - version: 19.2.4 - resolution: "@types/react-dom@npm:19.2.4" - peerDependencies: - "@types/react": ^19.2.0 - checksum: 10c0/b7d854ce17bb51a3a067168268a90f0123d10dc490f63f1ff5409f07ac715febeb8f5b7b473404a9d437106571e0312fc2937a945634d590abfc9aa46a14b01b - languageName: node - linkType: hard - -"@types/react@npm:^19.2.14": - version: 19.2.18 - resolution: "@types/react@npm:19.2.18" - dependencies: - csstype: "npm:^3.2.2" - checksum: 10c0/d04216172b4b4362b310017c210dfbe019fb4f4e7dffd0313e70b3acb3051d5c3e76e7e84e3cf4c6a3c824993ffadfe3949e589a6398a09d4200e7670e2962de - languageName: node - linkType: hard - -"@vitejs/plugin-react@npm:^6.0.2": - version: 6.0.5 - resolution: "@vitejs/plugin-react@npm:6.0.5" - dependencies: - "@rolldown/pluginutils": "npm:^1.0.1" - peerDependencies: - "@rolldown/plugin-babel": ^0.1.7 || ^0.2.0 - babel-plugin-react-compiler: ^1.0.0 - vite: ^8.0.0 - peerDependenciesMeta: - "@rolldown/plugin-babel": - optional: true - babel-plugin-react-compiler: - optional: true - checksum: 10c0/fb02246fe3652d7fb746190bdd098b9e29918dfcf8c67b9c0c37300ce789e82331b2ba761530ac6ac9a61390b774d44f401787bd1c87a6c866d1e74a745cc1a0 - languageName: node - linkType: hard - -"@vitest/expect@npm:4.1.10": - version: 4.1.10 - resolution: "@vitest/expect@npm:4.1.10" - dependencies: - "@standard-schema/spec": "npm:^1.1.0" - "@types/chai": "npm:^5.2.2" - "@vitest/spy": "npm:4.1.10" - "@vitest/utils": "npm:4.1.10" - chai: "npm:^6.2.2" - tinyrainbow: "npm:^3.1.0" - checksum: 10c0/a817ad0d9bd6a039776a7228d54fb8319c17e4af15917407f5566ac61781a8511f591d302519d6999217399915bc3c0290028189fc73f5c38f80cb01b6f19c8d - languageName: node - linkType: hard - -"@vitest/mocker@npm:4.1.10": - version: 4.1.10 - resolution: "@vitest/mocker@npm:4.1.10" - dependencies: - "@vitest/spy": "npm:4.1.10" - estree-walker: "npm:^3.0.3" - magic-string: "npm:^0.30.21" - peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - checksum: 10c0/4aa70b0df58681652e2e28093437fb2e8f4d02a6d03f5619abc266ac1c5ae5f43326148061d13ae6e071e0f6cfcf7634659af63644de8ce098a7c98949a3d1ad - languageName: node - linkType: hard - -"@vitest/pretty-format@npm:4.1.10": - version: 4.1.10 - resolution: "@vitest/pretty-format@npm:4.1.10" - dependencies: - tinyrainbow: "npm:^3.1.0" - checksum: 10c0/1a5daba730ffe23f2000bff484b4b2842f3b178d93663cb487b215516b8d3b62caa3e2bb2a3c63307b61a9fe58fb9bfff38559bc0c5e49d8aa403d6803a1d918 - languageName: node - linkType: hard - -"@vitest/runner@npm:4.1.10": - version: 4.1.10 - resolution: "@vitest/runner@npm:4.1.10" - dependencies: - "@vitest/utils": "npm:4.1.10" - pathe: "npm:^2.0.3" - checksum: 10c0/554b72639de9694271b99be8ae273fe12ec793093ec91cce143816cd1187d40b7138a4d9d4de4f456cfca9567de986825bff97e107c05b9eb4abc130e854286d - languageName: node - linkType: hard - -"@vitest/snapshot@npm:4.1.10": - version: 4.1.10 - resolution: "@vitest/snapshot@npm:4.1.10" - dependencies: - "@vitest/pretty-format": "npm:4.1.10" - "@vitest/utils": "npm:4.1.10" - magic-string: "npm:^0.30.21" - pathe: "npm:^2.0.3" - checksum: 10c0/e71398725f51af5fd0c07bb4b957d0f987daf9b4c564ac24cb2a4d1afde1a6939f535ac17761a32dcc41b0a1e6d4088af66dc44df89fdebebb92aabed1a92b5f - languageName: node - linkType: hard - -"@vitest/spy@npm:4.1.10": - version: 4.1.10 - resolution: "@vitest/spy@npm:4.1.10" - checksum: 10c0/e5c08012560af6727fd66741c5cda25560d7c5442103d0c83e4276a9b0dd90b9da6cdf823a461195229a16c6ff87768ce788a68d0fa29dea73ee285618668178 - languageName: node - linkType: hard - -"@vitest/utils@npm:4.1.10": - version: 4.1.10 - resolution: "@vitest/utils@npm:4.1.10" - dependencies: - "@vitest/pretty-format": "npm:4.1.10" - convert-source-map: "npm:^2.0.0" - tinyrainbow: "npm:^3.1.0" - checksum: 10c0/05b0ecec6997ec22fc08377e57dbd8fa37992e05961f3a7a916d98b1ab56d15c2a87dbd83d392b628242bdc156b1705e7fa60a3bf0c54bdb51158c153e05fc5d - languageName: node - linkType: hard - -"abbrev@npm:^5.0.0": - version: 5.0.0 - resolution: "abbrev@npm:5.0.0" - checksum: 10c0/8e88f5c798ea4562d28c5a3e9ad69e3879890bc5d695d8f2dffb8609be4c890aacc8f80ef4553fdd2c6a62d70c2ce8bc57b38074e383beb7487bdafa9ed42ea5 - languageName: node - linkType: hard - -"assertion-error@npm:^2.0.1": - version: 2.0.1 - resolution: "assertion-error@npm:2.0.1" - checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 - languageName: node - linkType: hard - -"babel-plugin-react-compiler@npm:^1.0.0": - version: 1.0.0 - resolution: "babel-plugin-react-compiler@npm:1.0.0" - dependencies: - "@babel/types": "npm:^7.26.0" - checksum: 10c0/9406267ada8d7dbdfe8906b40ecadb816a5f4cee2922bee23f7729293b369624ee135b5a9b0f263851c263c9787522ac5d97016c9a2b82d1668300e42b18aff8 - languageName: node - linkType: hard - -"baseline-browser-mapping@npm:^2.11.12": - version: 2.11.14 - resolution: "baseline-browser-mapping@npm:2.11.14" - bin: - baseline-browser-mapping: dist/cli.cjs - checksum: 10c0/b0d248660ed8a09b0738fb9ca383668e8e445ad23b3efa8a5c41ee91fdf09399766513f97e5effd77e2e5027c1eec225f84bbfdf01586871e5ba2bb4cce8ea83 - languageName: node - linkType: hard - -"bible-strong-avatar-lab@workspace:.": - version: 0.0.0-use.local - resolution: "bible-strong-avatar-lab@workspace:." - dependencies: - "@babel/core": "npm:^7.29.0" - "@babel/parser": "npm:^7.28.6" - "@base-ui/react": "npm:^1.7.0" - "@rolldown/plugin-babel": "npm:^0.2.3" - "@tailwindcss/vite": "npm:^4.3.3" - "@types/node": "npm:^24.10.1" - "@types/react": "npm:^19.2.14" - "@types/react-dom": "npm:^19.2.3" - "@vitejs/plugin-react": "npm:^6.0.2" - babel-plugin-react-compiler: "npm:^1.0.0" - class-variance-authority: "npm:^0.7.1" - clsx: "npm:^2.1.1" - lucide-react: "npm:^1.31.0" - motion: "npm:^13.0.0" - prettier: "npm:^3.8.1" - react: "npm:19.2.3" - react-dom: "npm:19.2.3" - rolldown: "npm:^1.0.0-rc.5" - tailwind-merge: "npm:^3.6.0" - tailwindcss: "npm:^4.3.3" - typescript: "npm:~6.0.3" - vite: "npm:^8.0.13" - vitest: "npm:^4.1.10" - languageName: unknown - linkType: soft - -"browserslist@npm:^4.24.0": - version: 4.28.8 - resolution: "browserslist@npm:4.28.8" - dependencies: - baseline-browser-mapping: "npm:^2.11.12" - caniuse-lite: "npm:^1.0.30001809" - electron-to-chromium: "npm:^1.5.402" - node-releases: "npm:^2.0.53" - update-browserslist-db: "npm:^1.3.0" - bin: - browserslist: cli.js - checksum: 10c0/047dd517c8d1f1f56a253d752c3127b8ad01c58e4cbb7e21cb5cd07ebd13e083a2237c3afb13eb60674282c9132bd4534aea2c3ff7c6f7d29be0687a00cbd537 - languageName: node - linkType: hard - -"caniuse-lite@npm:^1.0.30001809": - version: 1.0.30001809 - resolution: "caniuse-lite@npm:1.0.30001809" - checksum: 10c0/cac2ed4e66cc6c4cbf126b94d2c02012566eb92f93c89949290f99edcd2bdc4ed1290b5c537ccb9b42c773936a479ed468e5d4e9b8938b126a0947090e42e008 - languageName: node - linkType: hard - -"chai@npm:^6.2.2": - version: 6.2.2 - resolution: "chai@npm:6.2.2" - checksum: 10c0/e6c69e5f0c11dffe6ea13d0290936ebb68fcc1ad688b8e952e131df6a6d5797d5e860bc55cef1aca2e950c3e1f96daf79e9d5a70fb7dbaab4e46355e2635ed53 - languageName: node - linkType: hard - -"chownr@npm:^3.0.0": - version: 3.0.0 - resolution: "chownr@npm:3.0.0" - checksum: 10c0/43925b87700f7e3893296c8e9c56cc58f926411cce3a6e5898136daaf08f08b9a8eb76d37d3267e707d0dcc17aed2e2ebdf5848c0c3ce95cf910a919935c1b10 - languageName: node - linkType: hard - -"class-variance-authority@npm:^0.7.1": - version: 0.7.1 - resolution: "class-variance-authority@npm:0.7.1" - dependencies: - clsx: "npm:^2.1.1" - checksum: 10c0/0f438cea22131808b99272de0fa933c2532d5659773bfec0c583de7b3f038378996d3350683426b8e9c74a6286699382106d71fbec52f0dd5fbb191792cccb5b - languageName: node - linkType: hard - -"clsx@npm:^2.1.1": - version: 2.1.1 - resolution: "clsx@npm:2.1.1" - checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839 - languageName: node - linkType: hard - -"convert-source-map@npm:^2.0.0": - version: 2.0.0 - resolution: "convert-source-map@npm:2.0.0" - checksum: 10c0/8f2f7a27a1a011cc6cc88cc4da2d7d0cfa5ee0369508baae3d98c260bb3ac520691464e5bbe4ae7cdf09860c1d69ecc6f70c63c6e7c7f7e3f18ec08484dc7d9b - languageName: node - linkType: hard - -"csstype@npm:^3.2.2": - version: 3.2.3 - resolution: "csstype@npm:3.2.3" - checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce - languageName: node - linkType: hard - -"debug@npm:^4.1.0, debug@npm:^4.3.1": - version: 4.4.3 - resolution: "debug@npm:4.4.3" - dependencies: - ms: "npm:^2.1.3" - peerDependenciesMeta: - supports-color: - optional: true - checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 - languageName: node - linkType: hard - -"detect-libc@npm:^2.0.3": - version: 2.1.2 - resolution: "detect-libc@npm:2.1.2" - checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 - languageName: node - linkType: hard - -"electron-to-chromium@npm:^1.5.402": - version: 1.5.405 - resolution: "electron-to-chromium@npm:1.5.405" - checksum: 10c0/6e85abde0e3c77945c3cdd6f28741cba268474a62bfbe8c59c5728438a03e689d1df74fd4526a73064f452350ef7fd0b8edb721fa979a791813b81ec2b1a0d8c - languageName: node - linkType: hard - -"enhanced-resolve@npm:^5.24.1": - version: 5.24.5 - resolution: "enhanced-resolve@npm:5.24.5" - dependencies: - graceful-fs: "npm:^4.2.4" - tapable: "npm:^2.3.3" - checksum: 10c0/76c32ce5485ecea0ef808c9289bc6f7c49ce85142943598d89a23a846f2909556d3dc6db97a007442a48c185baad5514c1c76d20a2656497f9511d13ccfab73b - languageName: node - linkType: hard - -"env-paths@npm:^2.2.0": - version: 2.2.1 - resolution: "env-paths@npm:2.2.1" - checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4 - languageName: node - linkType: hard - -"es-module-lexer@npm:^2.0.0": - version: 2.3.1 - resolution: "es-module-lexer@npm:2.3.1" - checksum: 10c0/ada8b222772b5b8ea92eb6054c383233207418621855a07b480fdd36979b658a41414be09e793fcdd8a67a182741475f47830a01ff2ebd4353d7f6965c7c45f9 - languageName: node - linkType: hard - -"escalade@npm:^3.2.0": - version: 3.2.0 - resolution: "escalade@npm:3.2.0" - checksum: 10c0/ced4dd3a78e15897ed3be74e635110bbf3b08877b0a41be50dcb325ee0e0b5f65fc2d50e9845194d7c4633f327e2e1c6cce00a71b617c5673df0374201d67f65 - languageName: node - linkType: hard - -"estree-walker@npm:^3.0.3": - version: 3.0.3 - resolution: "estree-walker@npm:3.0.3" - dependencies: - "@types/estree": "npm:^1.0.0" - checksum: 10c0/c12e3c2b2642d2bcae7d5aa495c60fa2f299160946535763969a1c83fc74518ffa9c2cd3a8b69ac56aea547df6a8aac25f729a342992ef0bbac5f1c73e78995d - languageName: node - linkType: hard - -"expect-type@npm:^1.3.0": - version: 1.4.0 - resolution: "expect-type@npm:1.4.0" - checksum: 10c0/d40d76b8570695d36587beb3cc28494da2ca3ec8f04e67f5622ed2d372d850e401a9adef19c6835e1a8173903f157c79540b34c7b3fbd7cd8ce726cc903c57b7 - languageName: node - linkType: hard - -"exponential-backoff@npm:^3.1.1": - version: 3.1.3 - resolution: "exponential-backoff@npm:3.1.3" - checksum: 10c0/77e3ae682b7b1f4972f563c6dbcd2b0d54ac679e62d5d32f3e5085feba20483cf28bd505543f520e287a56d4d55a28d7874299941faf637e779a1aa5994d1267 - languageName: node - linkType: hard - -"fdir@npm:^6.5.0": - version: 6.5.0 - resolution: "fdir@npm:6.5.0" - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f - languageName: node - linkType: hard - -"framer-motion@npm:^13.1.0": - version: 13.1.0 - resolution: "framer-motion@npm:13.1.0" - dependencies: - motion-dom: "npm:^13.0.0" - motion-utils: "npm:^13.0.0" - tslib: "npm:^2.4.0" - peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - checksum: 10c0/0fa4c5d17369f288bfff434191fbd112d44ae06f7a99370e691128db1e302804332e5ec5e980c7a59ffcdb3248fe68f96bce5931d0c066eff55ec6d493fc197c - languageName: node - linkType: hard - -"fsevents@npm:~2.3.3": - version: 2.3.3 - resolution: "fsevents@npm:2.3.3" - dependencies: - node-gyp: "npm:latest" - checksum: 10c0/a1f0c44595123ed717febbc478aa952e47adfc28e2092be66b8ab1635147254ca6cfe1df792a8997f22716d4cbafc73309899ff7bfac2ac3ad8cf2e4ecc3ec60 - conditions: os=darwin - languageName: node - linkType: hard - -"fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": - version: 2.3.3 - resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" - dependencies: - node-gyp: "npm:latest" - conditions: os=darwin - languageName: node - linkType: hard - -"gensync@npm:^1.0.0-beta.2": - version: 1.0.0-beta.2 - resolution: "gensync@npm:1.0.0-beta.2" - checksum: 10c0/782aba6cba65b1bb5af3b095d96249d20edbe8df32dbf4696fd49be2583faf676173bf4809386588828e4dd76a3354fcbeb577bab1c833ccd9fc4577f26103f8 - languageName: node - linkType: hard - -"graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": - version: 4.2.11 - resolution: "graceful-fs@npm:4.2.11" - checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 - languageName: node - linkType: hard - -"isexe@npm:^4.0.0": - version: 4.0.0 - resolution: "isexe@npm:4.0.0" - checksum: 10c0/5884815115bceac452877659a9c7726382531592f43dc29e5d48b7c4100661aed54018cb90bd36cb2eaeba521092570769167acbb95c18d39afdccbcca06c5ce - languageName: node - linkType: hard - -"jiti@npm:^2.7.0": - version: 2.7.0 - resolution: "jiti@npm:2.7.0" - bin: - jiti: lib/jiti-cli.mjs - checksum: 10c0/1b1e2310a490dce1aeea3da5f5dfe18273516c20ce48be2e98eb8ea452d5f3dcc8fd0cfd6d28b4052a24c5dbab6e3089b2d7e79f0bce7915b10d750929563c42 - languageName: node - linkType: hard - -"js-tokens@npm:^4.0.0": - version: 4.0.0 - resolution: "js-tokens@npm:4.0.0" - checksum: 10c0/e248708d377aa058eacf2037b07ded847790e6de892bbad3dac0abba2e759cb9f121b00099a65195616badcb6eca8d14d975cb3e89eb1cfda644756402c8aeed - languageName: node - linkType: hard - -"jsesc@npm:^3.0.2": - version: 3.1.0 - resolution: "jsesc@npm:3.1.0" - bin: - jsesc: bin/jsesc - checksum: 10c0/531779df5ec94f47e462da26b4cbf05eb88a83d9f08aac2ba04206508fc598527a153d08bd462bae82fc78b3eaa1a908e1a4a79f886e9238641c4cdefaf118b1 - languageName: node - linkType: hard - -"json5@npm:^2.2.3": - version: 2.2.3 - resolution: "json5@npm:2.2.3" - bin: - json5: lib/cli.js - checksum: 10c0/5a04eed94810fa55c5ea138b2f7a5c12b97c3750bc63d11e511dcecbfef758003861522a070c2272764ee0f4e3e323862f386945aeb5b85b87ee43f084ba586c - languageName: node - linkType: hard - -"lightningcss-android-arm64@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-android-arm64@npm:1.32.0" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - -"lightningcss-android-arm64@npm:1.33.0": - version: 1.33.0 - resolution: "lightningcss-android-arm64@npm:1.33.0" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - -"lightningcss-darwin-arm64@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-darwin-arm64@npm:1.32.0" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"lightningcss-darwin-arm64@npm:1.33.0": - version: 1.33.0 - resolution: "lightningcss-darwin-arm64@npm:1.33.0" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"lightningcss-darwin-x64@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-darwin-x64@npm:1.32.0" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"lightningcss-darwin-x64@npm:1.33.0": - version: 1.33.0 - resolution: "lightningcss-darwin-x64@npm:1.33.0" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"lightningcss-freebsd-x64@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-freebsd-x64@npm:1.32.0" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - -"lightningcss-freebsd-x64@npm:1.33.0": - version: 1.33.0 - resolution: "lightningcss-freebsd-x64@npm:1.33.0" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - -"lightningcss-linux-arm-gnueabihf@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-linux-arm-gnueabihf@npm:1.32.0" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - -"lightningcss-linux-arm-gnueabihf@npm:1.33.0": - version: 1.33.0 - resolution: "lightningcss-linux-arm-gnueabihf@npm:1.33.0" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - -"lightningcss-linux-arm64-gnu@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-linux-arm64-gnu@npm:1.32.0" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - -"lightningcss-linux-arm64-gnu@npm:1.33.0": - version: 1.33.0 - resolution: "lightningcss-linux-arm64-gnu@npm:1.33.0" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - -"lightningcss-linux-arm64-musl@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-linux-arm64-musl@npm:1.32.0" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - -"lightningcss-linux-arm64-musl@npm:1.33.0": - version: 1.33.0 - resolution: "lightningcss-linux-arm64-musl@npm:1.33.0" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - -"lightningcss-linux-x64-gnu@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-linux-x64-gnu@npm:1.32.0" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - -"lightningcss-linux-x64-gnu@npm:1.33.0": - version: 1.33.0 - resolution: "lightningcss-linux-x64-gnu@npm:1.33.0" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - -"lightningcss-linux-x64-musl@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-linux-x64-musl@npm:1.32.0" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - -"lightningcss-linux-x64-musl@npm:1.33.0": - version: 1.33.0 - resolution: "lightningcss-linux-x64-musl@npm:1.33.0" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - -"lightningcss-win32-arm64-msvc@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-win32-arm64-msvc@npm:1.32.0" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - -"lightningcss-win32-arm64-msvc@npm:1.33.0": - version: 1.33.0 - resolution: "lightningcss-win32-arm64-msvc@npm:1.33.0" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - -"lightningcss-win32-x64-msvc@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-win32-x64-msvc@npm:1.32.0" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - -"lightningcss-win32-x64-msvc@npm:1.33.0": - version: 1.33.0 - resolution: "lightningcss-win32-x64-msvc@npm:1.33.0" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - -"lightningcss@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss@npm:1.32.0" - dependencies: - detect-libc: "npm:^2.0.3" - lightningcss-android-arm64: "npm:1.32.0" - lightningcss-darwin-arm64: "npm:1.32.0" - lightningcss-darwin-x64: "npm:1.32.0" - lightningcss-freebsd-x64: "npm:1.32.0" - lightningcss-linux-arm-gnueabihf: "npm:1.32.0" - lightningcss-linux-arm64-gnu: "npm:1.32.0" - lightningcss-linux-arm64-musl: "npm:1.32.0" - lightningcss-linux-x64-gnu: "npm:1.32.0" - lightningcss-linux-x64-musl: "npm:1.32.0" - lightningcss-win32-arm64-msvc: "npm:1.32.0" - lightningcss-win32-x64-msvc: "npm:1.32.0" - dependenciesMeta: - lightningcss-android-arm64: - optional: true - lightningcss-darwin-arm64: - optional: true - lightningcss-darwin-x64: - optional: true - lightningcss-freebsd-x64: - optional: true - lightningcss-linux-arm-gnueabihf: - optional: true - lightningcss-linux-arm64-gnu: - optional: true - lightningcss-linux-arm64-musl: - optional: true - lightningcss-linux-x64-gnu: - optional: true - lightningcss-linux-x64-musl: - optional: true - lightningcss-win32-arm64-msvc: - optional: true - lightningcss-win32-x64-msvc: - optional: true - checksum: 10c0/70945bd55097af46fc9fab7f5ed09cd5869d85940a2acab7ee06d0117004a1d68155708a2d462531cea2fc3c67aefc9333a7068c80b0b78dd404c16838809e03 - languageName: node - linkType: hard - -"lightningcss@npm:^1.33.0": - version: 1.33.0 - resolution: "lightningcss@npm:1.33.0" - dependencies: - detect-libc: "npm:^2.0.3" - lightningcss-android-arm64: "npm:1.33.0" - lightningcss-darwin-arm64: "npm:1.33.0" - lightningcss-darwin-x64: "npm:1.33.0" - lightningcss-freebsd-x64: "npm:1.33.0" - lightningcss-linux-arm-gnueabihf: "npm:1.33.0" - lightningcss-linux-arm64-gnu: "npm:1.33.0" - lightningcss-linux-arm64-musl: "npm:1.33.0" - lightningcss-linux-x64-gnu: "npm:1.33.0" - lightningcss-linux-x64-musl: "npm:1.33.0" - lightningcss-win32-arm64-msvc: "npm:1.33.0" - lightningcss-win32-x64-msvc: "npm:1.33.0" - dependenciesMeta: - lightningcss-android-arm64: - optional: true - lightningcss-darwin-arm64: - optional: true - lightningcss-darwin-x64: - optional: true - lightningcss-freebsd-x64: - optional: true - lightningcss-linux-arm-gnueabihf: - optional: true - lightningcss-linux-arm64-gnu: - optional: true - lightningcss-linux-arm64-musl: - optional: true - lightningcss-linux-x64-gnu: - optional: true - lightningcss-linux-x64-musl: - optional: true - lightningcss-win32-arm64-msvc: - optional: true - lightningcss-win32-x64-msvc: - optional: true - checksum: 10c0/ce1f8279fbae636dbf37fa6e7385d5f98ed881d72af3362f24afbd4685e19c1fcdfecf17e5dd77f2ebee3d0c23ade276230d85842d07292229a2cffba8ff20a3 - languageName: node - linkType: hard - -"lru-cache@npm:^5.1.1": - version: 5.1.1 - resolution: "lru-cache@npm:5.1.1" - dependencies: - yallist: "npm:^3.0.2" - checksum: 10c0/89b2ef2ef45f543011e38737b8a8622a2f8998cddf0e5437174ef8f1f70a8b9d14a918ab3e232cb3ba343b7abddffa667f0b59075b2b80e6b4d63c3de6127482 - languageName: node - linkType: hard - -"lucide-react@npm:^1.31.0": - version: 1.31.0 - resolution: "lucide-react@npm:1.31.0" - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10c0/c49e451279b46250e117ebe112c7b0b60712912571b7139ce9ea004e7d9125a2684d545a16e62706b2bc5e074cf557c9cc0471b437e0c868f59369324f9a6d5e - languageName: node - linkType: hard - -"magic-string@npm:^0.30.21": - version: 0.30.21 - resolution: "magic-string@npm:0.30.21" - dependencies: - "@jridgewell/sourcemap-codec": "npm:^1.5.5" - checksum: 10c0/299378e38f9a270069fc62358522ddfb44e94244baa0d6a8980ab2a9b2490a1d03b236b447eee309e17eb3bddfa482c61259d47960eb018a904f0ded52780c4a - languageName: node - linkType: hard - -"minipass@npm:^7.0.4, minipass@npm:^7.1.2": - version: 7.1.3 - resolution: "minipass@npm:7.1.3" - checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb - languageName: node - linkType: hard - -"minizlib@npm:^3.1.0": - version: 3.1.0 - resolution: "minizlib@npm:3.1.0" - dependencies: - minipass: "npm:^7.1.2" - checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec - languageName: node - linkType: hard - -"motion-dom@npm:^13.0.0": - version: 13.0.0 - resolution: "motion-dom@npm:13.0.0" - dependencies: - motion-utils: "npm:^13.0.0" - checksum: 10c0/3029e249515976f1eea67b26c88cfc6037e3ed3a821400e9e10a211dafad7b70f1d0e9243878dcc3011249e8fd84bc0c587a667fe750c74ad41418a85365f61e - languageName: node - linkType: hard - -"motion-utils@npm:^13.0.0": - version: 13.0.0 - resolution: "motion-utils@npm:13.0.0" - checksum: 10c0/4b279ce85f71926d668ff5966feb898b174541d16952a61c7150982388553266602ecd2a76e057530076ff8f38d06672e585639ddc73c41dd1a67d04aaa3662d - languageName: node - linkType: hard - -"motion@npm:^13.0.0": - version: 13.1.0 - resolution: "motion@npm:13.1.0" - dependencies: - framer-motion: "npm:^13.1.0" - tslib: "npm:^2.4.0" - peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - checksum: 10c0/2f974b22929945cf96d0164916fb04c470d993ab81ca9006bd590638edb35d04e202dc3f4e61fede4fd81bc9ef9d8a47555d73c776e428a357e09097e35b2750 - languageName: node - linkType: hard - -"ms@npm:^2.1.3": - version: 2.1.3 - resolution: "ms@npm:2.1.3" - checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48 - languageName: node - linkType: hard - -"nanoid@npm:^3.3.17": - version: 3.3.18 - resolution: "nanoid@npm:3.3.18" - bin: - nanoid: bin/nanoid.cjs - checksum: 10c0/b994b4e396730f8be2520923284e2040d61eaee55cc6d4935ef6d38d34bafdc46133eda4d3faea5073bda545aa6079d82b886caeac5c731cf9ac18bcc1301425 - languageName: node - linkType: hard - -"node-gyp@npm:latest": - version: 13.0.1 - resolution: "node-gyp@npm:13.0.1" - dependencies: - env-paths: "npm:^2.2.0" - exponential-backoff: "npm:^3.1.1" - graceful-fs: "npm:^4.2.6" - nopt: "npm:^10.0.0" - proc-log: "npm:^7.0.0" - semver: "npm:^7.3.5" - tar: "npm:^7.5.4" - tinyglobby: "npm:^0.2.12" - undici: "npm:^8.4.1" - which: "npm:^7.0.0" - bin: - node-gyp: bin/node-gyp.js - checksum: 10c0/424077bc9e9bbe953a8e86db473ba818cbc6a121714008c977fd589e21e5f0c811fbf22faac730dc7182450b5e52df301811d01ae3373898658d999b7710f4e6 - languageName: node - linkType: hard - -"node-releases@npm:^2.0.53": - version: 2.0.53 - resolution: "node-releases@npm:2.0.53" - checksum: 10c0/db9fb29b21ec12e223ead8bd9406100ff14fce01678a9a4865e288098456b84869431421a739f216aa520b6f57d9425f6537662501ae8a2d9e1771bfa87751bc - languageName: node - linkType: hard - -"nopt@npm:^10.0.0": - version: 10.0.1 - resolution: "nopt@npm:10.0.1" - dependencies: - abbrev: "npm:^5.0.0" - bin: - nopt: bin/nopt.js - checksum: 10c0/980d89257f9587f3e1f77877ddbf905d6aa3b738ec33e49a4fa1a059a0dd82eb28063982b150654a7ae9de386f2ead60e56172db7d37cf56de545f7392a2a26a - languageName: node - linkType: hard - -"obug@npm:^2.1.1": - version: 2.1.4 - resolution: "obug@npm:2.1.4" - checksum: 10c0/34a0ee97cd88573cfd97d384c2a79f07118ae5680d7e45d1de6e99c74eddefe145e8ca27a2db02195a1ee5fded5aa22b924869c842728c201b9f109a27d0ef19 - languageName: node - linkType: hard - -"pathe@npm:^2.0.3": - version: 2.0.3 - resolution: "pathe@npm:2.0.3" - checksum: 10c0/c118dc5a8b5c4166011b2b70608762e260085180bb9e33e80a50dcdb1e78c010b1624f4280c492c92b05fc276715a4c357d1f9edc570f8f1b3d90b6839ebaca1 - languageName: node - linkType: hard - -"picocolors@npm:^1.1.1": - version: 1.1.1 - resolution: "picocolors@npm:1.1.1" - checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 - languageName: node - linkType: hard - -"picomatch@npm:^4.0.3, picomatch@npm:^4.0.4, picomatch@npm:^4.0.5": - version: 4.0.5 - resolution: "picomatch@npm:4.0.5" - checksum: 10c0/947bc6b6e1ff1e6c5aaf95b107a0839d12802f4f7b867663f67d47accba939ca1cb582cf99dfc30438efa1c4648ac5990967e783e8929c36b03e8440704ef1bd - languageName: node - linkType: hard - -"postcss@npm:^8.5.25": - version: 8.5.26 - resolution: "postcss@npm:8.5.26" - dependencies: - nanoid: "npm:^3.3.17" - picocolors: "npm:^1.1.1" - source-map-js: "npm:^1.2.1" - checksum: 10c0/2bdafc00d96bd57b6649a52e458864a4bf58ee56cfdbe4aea1472b5cccc127e6c1ad653bd0bec50d211e650eb0b9270c80e1e72aff2e2fa40d9e7363234d6e43 - languageName: node - linkType: hard - -"prettier@npm:^3.8.1": - version: 3.9.6 - resolution: "prettier@npm:3.9.6" - bin: - prettier: bin/prettier.cjs - checksum: 10c0/9f7ddae234035868f3daab3dc34e6de7e54622185afb017378029f0c09a9c023248ccf6f34def0b17a0538e18c47aa1b3188bab72965d1bf735919baa0747e99 - languageName: node - linkType: hard - -"proc-log@npm:^7.0.0": - version: 7.0.0 - resolution: "proc-log@npm:7.0.0" - checksum: 10c0/b89c2d862604f35fec795477b0c7e376feab3ba0d4f4d291c4e959567442697cf451ac557d0623c1cc38af45a78128b983410f397a10c5d3a67f76c33de4754b - languageName: node - linkType: hard - -"react-dom@npm:19.2.3": - version: 19.2.3 - resolution: "react-dom@npm:19.2.3" - dependencies: - scheduler: "npm:^0.27.0" - peerDependencies: - react: ^19.2.3 - checksum: 10c0/dc43f7ede06f46f3acc16ee83107c925530de9b91d1d0b3824583814746ff4c498ea64fd65cd83aba363205268adff52e2827c582634ae7b15069deaeabc4892 - languageName: node - linkType: hard - -"react@npm:19.2.3": - version: 19.2.3 - resolution: "react@npm:19.2.3" - checksum: 10c0/094220b3ba3a76c1b668f972ace1dd15509b157aead1b40391d1c8e657e720c201d9719537375eff08f5e0514748c0319063392a6f000e31303aafc4471f1436 - languageName: node - linkType: hard - -"reselect@npm:^5.2.0": - version: 5.2.0 - resolution: "reselect@npm:5.2.0" - checksum: 10c0/d0a8497e404b25a769fe792eacae88b9b576c30870450cd243d76ec9427d91a59c4a2cc00d824d283448b444c4c698a8f961d771c8ae39c759313afb31950e2e - languageName: node - linkType: hard - -"rolldown@npm:^1.0.0-rc.5, rolldown@npm:~1.2.1": - version: 1.2.4 - resolution: "rolldown@npm:1.2.4" - dependencies: - "@oxc-project/types": "npm:=0.144.0" - "@rolldown/binding-android-arm64": "npm:1.2.4" - "@rolldown/binding-darwin-arm64": "npm:1.2.4" - "@rolldown/binding-darwin-x64": "npm:1.2.4" - "@rolldown/binding-freebsd-x64": "npm:1.2.4" - "@rolldown/binding-linux-arm-gnueabihf": "npm:1.2.4" - "@rolldown/binding-linux-arm64-gnu": "npm:1.2.4" - "@rolldown/binding-linux-arm64-musl": "npm:1.2.4" - "@rolldown/binding-linux-ppc64-gnu": "npm:1.2.4" - "@rolldown/binding-linux-s390x-gnu": "npm:1.2.4" - "@rolldown/binding-linux-x64-gnu": "npm:1.2.4" - "@rolldown/binding-linux-x64-musl": "npm:1.2.4" - "@rolldown/binding-openharmony-arm64": "npm:1.2.4" - "@rolldown/binding-win32-arm64-msvc": "npm:1.2.4" - "@rolldown/binding-win32-x64-msvc": "npm:1.2.4" - "@rolldown/pluginutils": "npm:^1.0.0" - dependenciesMeta: - "@rolldown/binding-android-arm64": - optional: true - "@rolldown/binding-darwin-arm64": - optional: true - "@rolldown/binding-darwin-x64": - optional: true - "@rolldown/binding-freebsd-x64": - optional: true - "@rolldown/binding-linux-arm-gnueabihf": - optional: true - "@rolldown/binding-linux-arm64-gnu": - optional: true - "@rolldown/binding-linux-arm64-musl": - optional: true - "@rolldown/binding-linux-ppc64-gnu": - optional: true - "@rolldown/binding-linux-s390x-gnu": - optional: true - "@rolldown/binding-linux-x64-gnu": - optional: true - "@rolldown/binding-linux-x64-musl": - optional: true - "@rolldown/binding-openharmony-arm64": - optional: true - "@rolldown/binding-win32-arm64-msvc": - optional: true - "@rolldown/binding-win32-x64-msvc": - optional: true - bin: - rolldown: ./bin/cli.mjs - checksum: 10c0/438c2222db940fab6e2db1f1cf84ab27c83310959e056fa389d7e2208bcbf58929f970f4dca5bd9c255f319bf657ef1a4a7ea6725213491d28764d867e5407a4 - languageName: node - linkType: hard - -"scheduler@npm:^0.27.0": - version: 0.27.0 - resolution: "scheduler@npm:0.27.0" - checksum: 10c0/4f03048cb05a3c8fddc45813052251eca00688f413a3cee236d984a161da28db28ba71bd11e7a3dd02f7af84ab28d39fb311431d3b3772fed557945beb00c452 - languageName: node - linkType: hard - -"semver@npm:^6.3.1": - version: 6.3.1 - resolution: "semver@npm:6.3.1" - bin: - semver: bin/semver.js - checksum: 10c0/e3d79b609071caa78bcb6ce2ad81c7966a46a7431d9d58b8800cfa9cb6a63699b3899a0e4bcce36167a284578212d9ae6942b6929ba4aa5015c079a67751d42d - languageName: node - linkType: hard - -"semver@npm:^7.3.5": - version: 7.8.5 - resolution: "semver@npm:7.8.5" - bin: - semver: bin/semver.js - checksum: 10c0/b1f3127a5be8125a94f37188b361c212466c292c6910adce3ec106cff5dc211ccaedc4739c11bb70fda59d6fc1f040a9bca289f4e093451521a2372e5231fe0c - languageName: node - linkType: hard - -"siginfo@npm:^2.0.0": - version: 2.0.0 - resolution: "siginfo@npm:2.0.0" - checksum: 10c0/3def8f8e516fbb34cb6ae415b07ccc5d9c018d85b4b8611e3dc6f8be6d1899f693a4382913c9ed51a06babb5201639d76453ab297d1c54a456544acf5c892e34 - languageName: node - linkType: hard - -"source-map-js@npm:^1.2.1": - version: 1.2.1 - resolution: "source-map-js@npm:1.2.1" - checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf - languageName: node - linkType: hard - -"stackback@npm:0.0.2": - version: 0.0.2 - resolution: "stackback@npm:0.0.2" - checksum: 10c0/89a1416668f950236dd5ac9f9a6b2588e1b9b62b1b6ad8dff1bfc5d1a15dbf0aafc9b52d2226d00c28dffff212da464eaeebfc6b7578b9d180cef3e3782c5983 - languageName: node - linkType: hard - -"std-env@npm:^4.0.0-rc.1": - version: 4.2.0 - resolution: "std-env@npm:4.2.0" - checksum: 10c0/40ac525ce7b7c556abc332a7376f14356eeb1a7f17f6ff9a003eb9f52326ff1f3745d3e1b43452675b1ec6fcc319f1b1d6f3b0d386cf3f91058479ad883cff69 - languageName: node - linkType: hard - -"tailwind-merge@npm:^3.6.0": - version: 3.6.0 - resolution: "tailwind-merge@npm:3.6.0" - checksum: 10c0/a728d929f4786bcb48641db4228cc8c006a7545491783a658fb4d24d177dab960620991d17805a047309228de50ab949ca8d9e112c30183da0432367796ae186 - languageName: node - linkType: hard - -"tailwindcss@npm:4.3.3, tailwindcss@npm:^4.3.3": - version: 4.3.3 - resolution: "tailwindcss@npm:4.3.3" - checksum: 10c0/7e9cd7553cd890ffa5350efb0ca8971ba5435257d0f98bf0714994ac8f1fee5c7cdf9a9da47fe83e8ac995d728410837ac49d3ef5ea66b5afe1db312086ffae2 - languageName: node - linkType: hard - -"tapable@npm:^2.3.3": - version: 2.3.3 - resolution: "tapable@npm:2.3.3" - checksum: 10c0/47992e861053f861154e92fb4a98ac4ab47b6463717e60792dd1e8c755da0c4964cd8bb68c308a9066d6da89000b6310457b4d5d985c30de4ccc29066068cc17 - languageName: node - linkType: hard - -"tar@npm:^7.5.4": - version: 7.5.22 - resolution: "tar@npm:7.5.22" - dependencies: - "@isaacs/fs-minipass": "npm:^4.0.0" - chownr: "npm:^3.0.0" - minipass: "npm:^7.1.2" - minizlib: "npm:^3.1.0" - yallist: "npm:^5.0.0" - checksum: 10c0/1311f6be85a8157ac4c9147bae43e13923d2a1aae15e4aa1bd5239e4e03d2cf53cfe103dde7f35832fbb4c938b042856bc8e9a0afd29abd05e2d1608788c4fea - languageName: node - linkType: hard - -"tinybench@npm:^2.9.0": - version: 2.9.0 - resolution: "tinybench@npm:2.9.0" - checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c - languageName: node - linkType: hard - -"tinyexec@npm:^1.0.2": - version: 1.3.0 - resolution: "tinyexec@npm:1.3.0" - checksum: 10c0/e9b89f97489d2aab2cef408da279e6b32547e738d1275032ccb8fd0028a006d93eb70fc51c6cffd9fc2f5aca6c2a273d8b6f73b52d46ee5116da6b94969ef958 - languageName: node - linkType: hard - -"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15, tinyglobby@npm:^0.2.17": - version: 0.2.17 - resolution: "tinyglobby@npm:0.2.17" - dependencies: - fdir: "npm:^6.5.0" - picomatch: "npm:^4.0.4" - checksum: 10c0/7f7bb0f197c88bc4b20c231e0deca4240ca3bf313a88f5a7fee93a872b84966a4d50220947c0455ad07a60b3b360961c5b7fd979222aeb716a9f99b412002e4c - languageName: node - linkType: hard - -"tinyrainbow@npm:^3.1.0": - version: 3.1.1 - resolution: "tinyrainbow@npm:3.1.1" - checksum: 10c0/f9d2743832c6191f753408f36224fe817620b8abcef572b2e570204c673a901d753ff84ca8e7b88f9c79e934295b3ffc6fcbc56a06f126e24e1ec6186dcad40d - languageName: node - linkType: hard - -"tslib@npm:^2.4.0, tslib@npm:^2.8.1": - version: 2.8.1 - resolution: "tslib@npm:2.8.1" - checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 - languageName: node - linkType: hard - -"typescript@npm:~6.0.3": - version: 6.0.3 - resolution: "typescript@npm:6.0.3" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/4a25ff5045b984370f48f196b3a0120779b1b343d40b9a68d114ea5e5fff099809b2bb777576991a63a5cd59cf7bffd96ff6fe10afcefbcb8bd6fb96ad4b6606 - languageName: node - linkType: hard - -"typescript@patch:typescript@npm%3A~6.0.3#optional!builtin": - version: 6.0.3 - resolution: "typescript@patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/2f25c74e65663c248fa1ade2b8459d9ce5372ff9dad07067310f132966ebec1d93f6c42f0baf77a6b6a7a91460463f708e6887013aaade22111037457c6b25df - languageName: node - linkType: hard - -"undici-types@npm:~7.18.0": - version: 7.18.2 - resolution: "undici-types@npm:7.18.2" - checksum: 10c0/85a79189113a238959d7a647368e4f7c5559c3a404ebdb8fc4488145ce9426fcd82252a844a302798dfc0e37e6fb178ff481ed03bc4caf634c5757d9ef43521d - languageName: node - linkType: hard - -"undici@npm:^8.4.1": - version: 8.10.0 - resolution: "undici@npm:8.10.0" - checksum: 10c0/37ae2b1db8f65c3a003504e2040e96887b99f9db16ba07dcf49c37ed8b7f8c72f4452f2d46f52f7c9e49518fe8325e3b5e7e02ac3a2424886bd67d4b62a0ecab - languageName: node - linkType: hard - -"update-browserslist-db@npm:^1.3.0": - version: 1.3.1 - resolution: "update-browserslist-db@npm:1.3.1" - dependencies: - escalade: "npm:^3.2.0" - picocolors: "npm:^1.1.1" - peerDependencies: - browserslist: ">= 4.21.0" - bin: - update-browserslist-db: cli.js - checksum: 10c0/2a924f5aa283af2d918e5cd941c7e8d77b05adf2ffbe5ca461506b1ff05fee6e8bca74c0f1ba37eb28f00ec93b15e80de24c7222825835aec2dd7bf0a0f6a417 - languageName: node - linkType: hard - -"use-sync-external-store@npm:^1.6.0": - version: 1.6.0 - resolution: "use-sync-external-store@npm:1.6.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10c0/35e1179f872a53227bdf8a827f7911da4c37c0f4091c29b76b1e32473d1670ebe7bcd880b808b7549ba9a5605c233350f800ffab963ee4a4ee346ee983b6019b - languageName: node - linkType: hard - -"vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0, vite@npm:^8.0.13": - version: 8.2.1 - resolution: "vite@npm:8.2.1" - dependencies: - fsevents: "npm:~2.3.3" - lightningcss: "npm:^1.33.0" - picomatch: "npm:^4.0.5" - postcss: "npm:^8.5.25" - rolldown: "npm:~1.2.1" - tinyglobby: "npm:^0.2.17" - peerDependencies: - "@types/node": ^20.19.0 || >=22.12.0 - "@vitejs/devtools": ^0.4.0 - esbuild: ^0.27.0 || ^0.28.0 - jiti: ">=1.21.0" - less: ^4.0.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: ">=0.54.8" - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - dependenciesMeta: - fsevents: - optional: true - peerDependenciesMeta: - "@types/node": - optional: true - "@vitejs/devtools": - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - bin: - vite: bin/vite.js - checksum: 10c0/e958dd07502deeb552f04dba59418ff1eb63183add416fd7a3e3badabf5d36edc6834b94c916f9f9233f976bb1671e3e9989d90e869059af07b0d43f7fc078c2 - languageName: node - linkType: hard - -"vitest@npm:^4.1.10": - version: 4.1.10 - resolution: "vitest@npm:4.1.10" - dependencies: - "@vitest/expect": "npm:4.1.10" - "@vitest/mocker": "npm:4.1.10" - "@vitest/pretty-format": "npm:4.1.10" - "@vitest/runner": "npm:4.1.10" - "@vitest/snapshot": "npm:4.1.10" - "@vitest/spy": "npm:4.1.10" - "@vitest/utils": "npm:4.1.10" - es-module-lexer: "npm:^2.0.0" - expect-type: "npm:^1.3.0" - magic-string: "npm:^0.30.21" - obug: "npm:^2.1.1" - pathe: "npm:^2.0.3" - picomatch: "npm:^4.0.3" - std-env: "npm:^4.0.0-rc.1" - tinybench: "npm:^2.9.0" - tinyexec: "npm:^1.0.2" - tinyglobby: "npm:^0.2.15" - tinyrainbow: "npm:^3.1.0" - vite: "npm:^6.0.0 || ^7.0.0 || ^8.0.0" - why-is-node-running: "npm:^2.3.0" - peerDependencies: - "@edge-runtime/vm": "*" - "@opentelemetry/api": ^1.9.0 - "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 - "@vitest/browser-playwright": 4.1.10 - "@vitest/browser-preview": 4.1.10 - "@vitest/browser-webdriverio": 4.1.10 - "@vitest/coverage-istanbul": 4.1.10 - "@vitest/coverage-v8": 4.1.10 - "@vitest/ui": 4.1.10 - happy-dom: "*" - jsdom: "*" - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - "@edge-runtime/vm": - optional: true - "@opentelemetry/api": - optional: true - "@types/node": - optional: true - "@vitest/browser-playwright": - optional: true - "@vitest/browser-preview": - optional: true - "@vitest/browser-webdriverio": - optional: true - "@vitest/coverage-istanbul": - optional: true - "@vitest/coverage-v8": - optional: true - "@vitest/ui": - optional: true - happy-dom: - optional: true - jsdom: - optional: true - vite: - optional: false - bin: - vitest: ./vitest.mjs - checksum: 10c0/ff07294a57f9c62f3b503f7cf88a52ee0753ed26389a49cda430387a3898f39d80af47180b0af19e27acab5bd11ae95706bd4b44ce8befc97d3ae49af6ca4fc1 - languageName: node - linkType: hard - -"which@npm:^7.0.0": - version: 7.0.0 - resolution: "which@npm:7.0.0" - dependencies: - isexe: "npm:^4.0.0" - bin: - node-which: bin/which.js - checksum: 10c0/ca0b54f198f78bbc4b7c02e34bda8d335cb352e0adb4cbca1c37b1a957af3a879a82c4c27ca6525bc942f548d8b64f816ef6528360af9f3de55ffb9b979b620d - languageName: node - linkType: hard - -"why-is-node-running@npm:^2.3.0": - version: 2.3.0 - resolution: "why-is-node-running@npm:2.3.0" - dependencies: - siginfo: "npm:^2.0.0" - stackback: "npm:0.0.2" - bin: - why-is-node-running: cli.js - checksum: 10c0/1cde0b01b827d2cf4cb11db962f3958b9175d5d9e7ac7361d1a7b0e2dc6069a263e69118bd974c4f6d0a890ef4eedfe34cf3d5167ec14203dbc9a18620537054 - languageName: node - linkType: hard - -"yallist@npm:^3.0.2": - version: 3.1.1 - resolution: "yallist@npm:3.1.1" - checksum: 10c0/c66a5c46bc89af1625476f7f0f2ec3653c1a1791d2f9407cfb4c2ba812a1e1c9941416d71ba9719876530e3340a99925f697142989371b72d93b9ee628afd8c1 - languageName: node - linkType: hard - -"yallist@npm:^5.0.0": - version: 5.0.0 - resolution: "yallist@npm:5.0.0" - checksum: 10c0/a499c81ce6d4a1d260d4ea0f6d49ab4da09681e32c3f0472dee16667ed69d01dae63a3b81745a24bd78476ec4fcf856114cb4896ace738e01da34b2c42235416 - languageName: node - linkType: hard