diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54a42d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +dist/ +node_modules/ +.yarn/ +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions +*.local +.DS_Store +coverage/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..1d9b783 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.12.0 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..4754fa6 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +dist/ +node_modules/ +legacy/ +src/defaultStudioDocument.json +src/standaloneEngine.generated.ts +yarn.lock diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..fca7ef8 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": false, + "singleQuote": true, + "jsxSingleQuote": false, + "bracketSpacing": true, + "arrowParens": "avoid", + "printWidth": 100 +} diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..55dcece --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# AGENTS.md + +## Project + +Bible Strong Avatar Lab is a standalone React 19 + Vite 8 application for procedural SVG avatars. +Read `CONTEXT.md` before changing behavior or persistence semantics. + +## Commands + +```bash +yarn dev +yarn typecheck +yarn test +yarn build +yarn check +``` + +## Engineering rules + +- Use TypeScript in strict mode. +- Keep geometry, playback and document operations framework-independent. +- Preserve the split between durable React state and high-frequency Motion values. +- Do not use `useMemo`, `useCallback` or `memo`; React Compiler handles memoization. +- Add or update focused tests for domain behavior. +- Run `yarn typecheck` and the relevant test file while working, then `yarn check` before committing. +- `src/standaloneEngine.generated.ts` is generated. Update it with `yarn engine`, never by hand. +- Keep English, French and Simplified Chinese copy synchronized across `src/i18n.ts` and `src/i18n.zh.ts`. +- Preserve project JSON compatibility only with the current pre-release schema unless a migration is explicitly requested. + +## UI conventions + +- Reuse components under `src/components/ui/` rather than adding one-off controls. +- Keep selection, hover, focus and destructive actions visually consistent across avatars, expressions and animations. +- Prefer transform/opacity animation over layout animation for continuous interactions. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..f8b0c29 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,68 @@ +# Avatar Lab Context + +## Purpose + +Bible Strong Avatar Lab is a browser-based authoring tool for procedural 2D avatars. It combines +3D-inspired geometry with SVG rendering so creators can build a body from primitives, define a +neutral face, author expressions and compose reusable animations. Generated packages do not depend +on the Studio UI. + +## Ubiquitous language + +**Avatar** +A reusable character whose body, colors and neutral eyes define its persistent visual identity. + +**Neutral appearance** +The persistent visual base of an Avatar before an Expression is applied. Avoid “default pose”. + +**Pose** +The Avatar's current temporary visual configuration. A Pose is not persisted directly. + +**Base behavior library** +The immutable bundled collection of Expressions and Animations inherited by an Avatar that has not +customized its behavior. + +**Avatar behavior library** +The Avatar-owned collection of Expressions and Animations created atomically from the Base behavior +library on that Avatar's first behavior mutation. + +**Expression** +A saved visual preset within the Base behavior library or one Avatar behavior library. Eye values +are relative to the owning Avatar's Neutral appearance. Optional color overrides are temporary. + +**Animation** +A saved sequence of Expressions from the same behavior library, including transitions and blink +behavior. Avoid “state” in user-facing copy. + +**Playback** +The active execution of one Animation. It exclusively controls the animated Pose until direct user +manipulation pauses it. + +## Invariants + +- An Avatar inherits the Base behavior library until its first Expression or Animation mutation. +- The first behavior mutation copies Expressions and Animations together so references stay valid. +- Subsequent behavior mutations affect only the owning Avatar. +- Duplicating an Avatar duplicates its Avatar behavior library when one exists. +- Transferring an Animation must also transfer every Expression it references. +- The primary body shape carries the facial coordinate system and eyes. +- Secondary primitives have independent local dimensions, position and rotation. +- Expressions remain compatible across body surfaces because they operate in the common facial frame. +- `geometry.ts` and the exported procedural engine stay independent from React. +- `defaultStudioDocument.json` is the current schema baseline; pre-release legacy migrations are not required. +- Live editing may update the preview, but unsaved avatar/expression edits must remain reversible. + +## Architecture boundaries + +- Pure geometry and document logic live in framework-independent TypeScript modules. +- `App.tsx` coordinates UI and application state; avoid moving domain calculations into components. +- Motion values own frame-by-frame visual updates; React state owns durable editor state. +- Exports receive explicit avatar, expression and animation data rather than reading browser storage. +- The generated standalone engine is committed and checked for freshness in local validation. + +## Current persistence + +The complete Studio document is persisted in browser local storage. JSON project export/import is the +portable backup mechanism. There is no remote backend in this repository. + +Accepted architecture decisions live under `docs/adr/`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b61f2e2 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# Bible Strong Avatar Lab + +Standalone React/Vite studio for creating procedural 2D avatars, expressions and animations. +The project was extracted from Bible Strong so it can evolve independently. + +## Start + +Requirements: Node.js 22.12+ and Corepack/Yarn 4. + +```bash +corepack enable +yarn install +yarn dev +``` + +Open `http://localhost:5173/`. + +## Validation + +```bash +yarn typecheck +yarn test +yarn build +``` + +`yarn check` runs the generated-engine freshness check and the full validation suite. + +## Project map + +- `src/App.tsx`: Studio interface and orchestration. +- `src/geometry.ts`: framework-independent procedural projection. +- `src/surfaces.ts`: supported head surfaces and primitive definitions. +- `src/renderedScene.ts`: stable render layer backed by Motion values. +- `src/playback.ts`: expression and animation playback. +- `src/studioDocument.ts`: versioned project document and browser persistence. +- `src/exporter.ts`: React/TypeScript and standalone JavaScript exports. +- `src/snapshotExporter.ts`: static SVG and PNG photo-mode exports. +- `src/defaultStudioDocument.json`: bundled avatars, expressions and animations. +- `scripts/generate-standalone-engine.mjs`: embeds the procedural runtime used by exports. +- `legacy/`: the two self-contained HTML prototypes that preceded the React app. +- `docs/adr/`: accepted architecture decisions inherited from the prototype. + +## Product model + +- An avatar owns its body and neutral eye appearance. +- The bundled behavior library supplies the initial expressions and animations. +- On the first behavior edit, an avatar receives its own independent copy of both. +- An animation references expressions from the same behavior library. +- The Studio document can be exported/imported as JSON for portability. + +See [CONTEXT.md](./CONTEXT.md) for the domain language and architecture constraints. diff --git a/components.json b/components.json new file mode 100644 index 0000000..b86ac10 --- /dev/null +++ b/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/docs/adr/0022-model-avatar-studio-as-a-versioned-document.md b/docs/adr/0022-model-avatar-studio-as-a-versioned-document.md new file mode 100644 index 0000000..a8df01e --- /dev/null +++ b/docs/adr/0022-model-avatar-studio-as-a-versioned-document.md @@ -0,0 +1,26 @@ +# ADR-0022: Model Avatar Studio as a versioned document + +## Status + +Accepted + +## Context + +Avatar Studio previously persisted Avatars, Expressions, Animations, and Playback separately. Animation +steps referenced Expressions by their current array position, so inserting or deleting an +Expression required coordinated remapping and could leave partially persisted data. + +## Decision + +Avatar Studio persists one versioned Studio document. Every Expression has a durable identity and +State steps reference that identity. Existing local data is migrated automatically by resolving +legacy expression indexes against the migrated Expression catalog. An Avatar owns its Neutral +appearance; global Expressions describe relative changes, and optional Expression colors remain +temporary overrides. Playback exclusively owns the animated Pose until a direct user manipulation +pauses it. Changing Avatar preserves the current Playback position. + +## Consequences + +Studio mutations preserve cross-object invariants in one transaction. Reordering Expressions no +longer changes Animation meaning. The project is pre-release and the bundled version-2 document is +the current baseline; no compatibility migration for earlier prototypes is maintained. diff --git a/docs/adr/0023-name-avatar-sequences-animations.md b/docs/adr/0023-name-avatar-sequences-animations.md new file mode 100644 index 0000000..0566ffc --- /dev/null +++ b/docs/adr/0023-name-avatar-sequences-animations.md @@ -0,0 +1,26 @@ +# ADR-0023: Name Avatar Studio sequences Animations + +## Status + +Accepted + +## Context + +ADR-0022 introduced the technical `State` model for a persisted sequence of Expressions. In the +Studio interface, “State” did not clearly communicate that the object is a complete, playable +animation. “Sequence” was also used for the same object in editor copy, creating three competing +names. + +## Decision + +The product term is **Animation**. An Animation contains ordered Expression steps, transitions, +playback behavior and blink settings. **Playback** remains the running execution of an Animation. + +Existing internal identifiers such as `AvatarSequence`, `stateId` and legacy storage keys may remain +until a dedicated migration is justified. They must not leak into user-facing copy or exported APIs. + +## Consequences + +Avatar Studio uses one consistent user-facing term in its tabs, editor, player and export flow. +The current versioned document remains unchanged because this decision changes terminology rather +than its internal shape. diff --git a/index.html b/index.html new file mode 100644 index 0000000..8c86814 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Bible Strong Avatar Lab + + +
+ + + diff --git a/legacy/index.html b/legacy/index.html new file mode 100644 index 0000000..6cba140 --- /dev/null +++ b/legacy/index.html @@ -0,0 +1,4015 @@ + + + + + + + + Atelier GrokBot — prototype + + + +
+
+ + + +

+ Le visage combine trois systèmes indépendants : des contours SVG dessinés à l’avance, un + petit déplacement du regard, puis une projection sphérique utilisée pendant les rotations. + Les états choisissent et enchaînent ces briques. +

+
+
+ +
+
+
+ + + + +
+ +
+
+
+
+

1. Expression SVG

+

+ Chaque bouton charge deux chemins de 48 points. Les grandes différences de + position et de taille sont déjà contenues dans ces chemins. +

+
+ +
+ +
+

2. Regard indépendant

+ + +
+ Limites exactes : le pointeur est borné à ±0,6, puis + multiplié par 22 horizontalement et 14 verticalement, soit environ ±13,2 × + ±8,4 unités. +
+
+ +
+

3. Rotation et profondeur

+ + +
+ +
+
+ +
+
+ +
+
+ Effet périphérique : un œil proche du bord voit sa largeur + multipliée par un rapport de cosinus. Lorsqu’il passe derrière la tête, il + devient invisible. +
+
+ +
+

4. Clignement et cadence

+
+ +
+
+ +
+

+ Un clignement dure 320 ms : fermeture pendant 42 % du temps, réouverture + pendant 58 %, avec une hauteur minimale de 4 %. +

+
+
+
+
+ + + + + + +
+
+
+ +
+ + + +
+
+ + diff --git a/legacy/spherical-eyes.html b/legacy/spherical-eyes.html new file mode 100644 index 0000000..4a9805f --- /dev/null +++ b/legacy/spherical-eyes.html @@ -0,0 +1,2894 @@ + + + + + + Yeux sphériques — prototype + + + + +
+
+
+ + Deux yeux capsules projetés sur une sphère + + Les contours des yeux sont recalculés en direct selon la rotation de la tête et leurs + paramètres locaux. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + L + H + R + S + E + + +

+ Glisse la boule pour tourner la tête. Clique un œil pour afficher ses poignées : + largeur, hauteur, rotation, taille globale et espacement. Les anneaux colorés règlent + X/Y/Z dans le coin supérieur droit ; l’anneau jaune tourne autour de la caméra. +

+
+
+ +
+

Prototype 02 · géométrie procédurale

+

Deux yeux.
Une seule forme.

+

+ Chaque œil est un rectangle long totalement arrondi. La taille et l’inclinaison sont + définies localement, puis la rotation de tête déforme réellement le contour sur une sphère + 3D avant de le ramener dans le SVG. +

+ + + +
+
+

Rotation de la tête

+

+ Les trois axes utilisent de vraies rotations 3D successives. Le drag de la boule est + libre ; les anneaux colorés contraignent X/Y/Z et l’anneau jaune suit la caméra. +

+ + + +
+ arcball · q(1, 0, 0, 0) +
+
+ +
+

Forme commune

+

+ Les deux yeux partagent la même capsule de base. On peut l’allonger, l’épaissir, + l’écarter et redimensionner tout le regard. +

+
+
+ Largeur + +
+
+ + +
+
+
+
+ Hauteur + +
+
+ + +
+
+
+
+ Taille globale + +
+
+ + +
+
+ + +
+ +
+

Rotation des yeux

+

+ La rotation se fait avant la projection : même inclinée, la capsule épouse ensuite la + courbure de la tête. +

+ + +
+ +
+

Inspection

+

+ Les guides permettent de voir que le résultat vient de points 3D et non d’une simple + transformation CSS du SVG entier. +

+ + + +
+ gauche · visible + droite · visible + silhouette · rayon 120.00 u +
+ +
+
+ + + + +
+
+ + +
+
+
+

Modifier l’expression 00

+

Les modifications restent locales à ce prototype.

+
+ +
+
+ +
+
+ +
+
+ + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..e239415 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "bible-strong-avatar-lab", + "version": "0.1.0", + "private": true, + "type": "module", + "packageManager": "yarn@4.12.0", + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "dev": "yarn engine && vite", + "build": "yarn engine && vite build", + "preview": "vite preview", + "engine": "node scripts/generate-standalone-engine.mjs", + "engine:check": "node scripts/generate-standalone-engine.mjs --check", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "format": "prettier --write .", + "format:check": "prettier --check .", + "check": "yarn engine:check && yarn format:check && yarn typecheck && yarn test && yarn build" + }, + "dependencies": { + "@base-ui/react": "^1.7.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.31.0", + "motion": "^13.0.0", + "react": "19.2.3", + "react-dom": "19.2.3", + "tailwind-merge": "^3.6.0" + }, + "devDependencies": { + "@babel/core": "^7.29.0", + "@babel/parser": "^7.28.6", + "@rolldown/plugin-babel": "^0.2.3", + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^24.10.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "babel-plugin-react-compiler": "^1.0.0", + "prettier": "^3.8.1", + "rolldown": "^1.0.0-rc.5", + "tailwindcss": "^4.3.3", + "typescript": "~6.0.3", + "vite": "^8.0.13", + "vitest": "^4.1.10" + } +} diff --git a/scripts/generate-standalone-engine.mjs b/scripts/generate-standalone-engine.mjs new file mode 100644 index 0000000..34274cc --- /dev/null +++ b/scripts/generate-standalone-engine.mjs @@ -0,0 +1,33 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { build } from 'vite' + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))) +const result = await build({ + configFile: false, + logLevel: 'silent', + build: { + write: false, + minify: true, + lib: { + entry: path.join(root, 'src/standaloneEngine.entry.ts'), + formats: ['iife'], + name: 'AvatarProceduralEngine', + }, + }, +}) +const outputs = Array.isArray(result) ? result.flatMap(item => item.output) : result.output +const chunk = outputs.find(item => item.type === 'chunk') +if (!chunk) throw new Error('Standalone avatar engine bundle was not generated.') + +const outputPath = path.join(root, 'src/standaloneEngine.generated.ts') +const source = `// Generated by scripts/generate-standalone-engine.mjs. Do not edit.\nexport const standaloneEngineSource = ${JSON.stringify(chunk.code)}\n` + +if (process.argv.includes('--check')) { + const current = await readFile(outputPath, 'utf8').catch(() => '') + if (current !== source) throw new Error('Standalone avatar engine is stale. Run yarn engine.') +} else { + await writeFile(outputPath, source) +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..75e9096 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,6015 @@ +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/__tests__/ambient-motion-test.ts b/src/__tests__/ambient-motion-test.ts new file mode 100644 index 0000000..8ebdcdc --- /dev/null +++ b/src/__tests__/ambient-motion-test.ts @@ -0,0 +1,46 @@ +import { ambientBodyOffset, applyAmbientMotion, hasAmbientMotion } from '../ambientMotion' +import { defaultExpression } from '../presets' + +describe('perpetual expression motion', () => { + it('leaves a motionless expression unchanged', () => { + const expression = { ...defaultExpression } + + expect(hasAmbientMotion(expression)).toBe(false) + expect(applyAmbientMotion(expression, 500)).toEqual(expression) + }) + + it('moves both eyes together without mutating the saved expression', () => { + const expression = { ...defaultExpression, eyeMotion: 'microSaccades' as const } + const animated = applyAmbientMotion(expression, 500) + + expect(animated.positionXLeft - expression.positionXLeft).toBeCloseTo( + animated.positionXRight - expression.positionXRight + ) + expect(animated.positionYLeft - expression.positionYLeft).toBeCloseTo( + animated.positionYRight - expression.positionYRight + ) + expect(animated.positionXLeft).not.toBe(expression.positionXLeft) + expect(expression.positionXLeft).toBe(defaultExpression.positionXLeft) + }) + + it('adds body motion without changing eye placement', () => { + const expression = { ...defaultExpression, bodyMotion: 'shake' as const } + const animated = applyAmbientMotion(expression, 500) + + expect(animated.headY).not.toBe(expression.headY) + expect(animated.positionXLeft).toBe(expression.positionXLeft) + expect(animated.positionYRight).toBe(expression.positionYRight) + }) + + it('visibly offsets a symmetric body when it shakes', () => { + const expression = { ...defaultExpression, bodyMotion: 'shake' as const } + + expect(ambientBodyOffset(expression, 500)).not.toEqual({ x: 0, y: 0 }) + }) + + it('visibly offsets a symmetric body during slow drift', () => { + const expression = { ...defaultExpression, bodyMotion: 'slowDrift' as const } + + expect(ambientBodyOffset(expression, 1500)).not.toEqual({ x: 0, y: 0 }) + }) +}) diff --git a/src/__tests__/avatars-test.ts b/src/__tests__/avatars-test.ts new file mode 100644 index 0000000..612bd9a --- /dev/null +++ b/src/__tests__/avatars-test.ts @@ -0,0 +1,59 @@ +import { defaultExpression } from '../presets' +import { createInitialSequences } from '../sequences' +import { + applyAvatarEyeDefaults, + cloneAvatarBehavior, + createAvatar, + defaultAvatarEyes, + parseAvatarEyeDefaults, + resolveAvatarBehavior, +} from '../avatars' +import { initialExpressions } from '../presets' + +describe('avatar eye defaults', () => { + it('keeps the historical rendering when using default values', () => { + expect(applyAvatarEyeDefaults(defaultExpression, defaultAvatarEyes)).toEqual(defaultExpression) + }) + + it('composes avatar defaults as variations around the neutral expression', () => { + const expression = { ...defaultExpression, widthLeft: 28, positionYLeft: 5 } + const eyes = { ...defaultAvatarEyes, widthLeft: 30, positionYLeft: -12 } + + const result = applyAvatarEyeDefaults(expression, eyes) + + expect(result.widthLeft).toBe(38) + expect(result.positionYLeft).toBe(0) + expect(expression.widthLeft).toBe(28) + }) + + it('sanitizes partial persisted values', () => { + const result = parseAvatarEyeDefaults({ widthLeft: 42, heightRight: Number.NaN }) + + expect(result.widthLeft).toBe(42) + expect(result.heightRight).toBe(defaultAvatarEyes.heightRight) + expect(result.spacing).toBe(defaultAvatarEyes.spacing) + }) +}) + +describe('avatar behavior library', () => { + const base = { + expressions: initialExpressions, + sequences: createInitialSequences(), + } + + it('inherits the base library until the avatar owns a customization', () => { + const avatar = createAvatar('Strobi') + + expect(resolveAvatarBehavior(avatar, base)).toBe(base) + }) + + it('clones expressions, animations and nested steps as one independent library', () => { + const behavior = cloneAvatarBehavior(base) + + expect(behavior).not.toBe(base) + expect(behavior.expressions).not.toBe(base.expressions) + expect(behavior.sequences).not.toBe(base.sequences) + expect(behavior.sequences[0].steps).not.toBe(base.sequences[0].steps) + expect(behavior.sequences[0].blink).not.toBe(base.sequences[0].blink) + }) +}) diff --git a/src/__tests__/canvas-preview-test.ts b/src/__tests__/canvas-preview-test.ts new file mode 100644 index 0000000..8b37226 --- /dev/null +++ b/src/__tests__/canvas-preview-test.ts @@ -0,0 +1,37 @@ +import { applyAvatarEyeDefaults, defaultAvatarEyes } from '../avatars' +import { resolveCanvasPreviewExpression } from '../canvasPreview' +import { defaultExpression } from '../presets' + +describe('canvas preview expression', () => { + it('keeps customized neutral eyes applied while rotating the body preview', () => { + const eyes = { + ...defaultAvatarEyes, + widthLeft: 37, + positionYLeft: -18, + leftAngle: 21, + rightAngle: -21, + } + const draggedExpression = { ...defaultExpression, headX: 24, headY: 31 } + + expect(resolveCanvasPreviewExpression(draggedExpression, eyes, true, 'head')).toEqual( + applyAvatarEyeDefaults(draggedExpression, eyes) + ) + }) + + it('does not compose neutral eyes twice outside body editing', () => { + const eyes = { ...defaultAvatarEyes, widthLeft: 37 } + + expect(resolveCanvasPreviewExpression(defaultExpression, eyes, false, 'head')).toBe( + defaultExpression + ) + }) + + it('does not compose customized eyes twice while editing an eye handle', () => { + const eyes = { ...defaultAvatarEyes, widthLeft: 37 } + const customizedExpression = applyAvatarEyeDefaults(defaultExpression, eyes) + + expect(resolveCanvasPreviewExpression(customizedExpression, eyes, true, 'eyes')).toBe( + customizedExpression + ) + }) +}) diff --git a/src/__tests__/exporter-test.ts b/src/__tests__/exporter-test.ts new file mode 100644 index 0000000..d6cf933 --- /dev/null +++ b/src/__tests__/exporter-test.ts @@ -0,0 +1,154 @@ +import { createAvatar } from '../avatars' +import { parse } from '@babel/parser' +import { + createAvatarExportPayload, + generateJavaScriptAvatarHtml, + generateJavaScriptAvatarModule, + generateJavaScriptAvatarPackage, + generateReactAvatarComponent, + generateReactAvatarPackage, + generateReactAvatarRuntime, +} from '../exporter' +import { initialExpressions } from '../presets' +import { createInitialSequences } from '../sequences' + +describe('avatar export', () => { + const avatar = createAvatar('Strobi') + const animations = createInitialSequences().filter(item => + ['idle', 'listening'].includes(item.id) + ) + const payload = createAvatarExportPayload(avatar, initialExpressions, animations) + + it('includes only the selected animations and their referenced expressions', () => { + expect(Object.keys(payload.animations)).toEqual(['idle', 'listening']) + expect(Object.keys(payload.expressions).sort()).toEqual( + [ + ...new Set(animations.flatMap(animation => animation.steps.map(step => step.expressionId))), + ].sort() + ) + expect(payload).not.toHaveProperty('frames') + expect(payload.avatar.name).toBe('Strobi') + }) + + it('generates a standalone JavaScript module without a Web Component', () => { + const source = generateJavaScriptAvatarModule(payload) + + expect(source).toContain('export function createAvatar') + expect(source).toContain('play(animationName)') + expect(source).toContain('export const availableAnimations') + expect(source).toContain('pausedRemainingMs') + expect(source).toContain('pausedBlinkDelay') + expect(source).toContain('pausedBlink') + expect(source).toContain('step.transition') + expect(source).toContain('stepIndex = 0') + expect(source).not.toContain('customElements.define') + expect(source).not.toContain("from '") + expect(() => parse(source, { sourceType: 'module' })).not.toThrow() + }) + + it('uses the Studio geometry engine to render interpolated transition frames', () => { + const source = generateJavaScriptAvatarModule(payload) + + expect(source).toContain('AvatarProceduralEngine') + expect(source).toContain('expressionFields') + expect(source).toContain('requestAnimationFrame') + expect(source).not.toContain('avatarFrameIn') + }) + + it('generates an HTML demo that works when opened directly from the filesystem', () => { + const source = generateJavaScriptAvatarHtml(payload) + + expect(source).not.toContain("import { createAvatar } from './avatar.js'") + expect(source).toContain('function createAvatar') + expect(source).toContain('avatar.play("idle")') + }) + + it('generates an interactive playground for the exported animations', () => { + const source = generateJavaScriptAvatarHtml(payload) + + expect(source).toContain('id="animation-list"') + expect(source).toContain('id="play-animation"') + expect(source).toContain('id="pause-animation"') + expect(source).toContain('id="stop-animation"') + expect(source).toContain('availableAnimations.forEach') + expect(source).toContain('avatar.play(activeAnimation)') + expect(source).toContain('avatar.pause()') + expect(source).toContain('avatar.stop()') + expect(source).toContain("createAvatar('#avatar'") + }) + + it('localizes the exported playground', () => { + const source = generateJavaScriptAvatarHtml(payload, 'fr') + + expect(source).toContain('') + expect(source).toContain('Choisissez une animation exportée') + expect(source).toContain('▶ Lire') + expect(source).toContain('En pause') + }) + + it('keeps user-authored names and descriptions inside the generated script', () => { + const source = generateJavaScriptAvatarHtml({ + ...payload, + avatar: { ...payload.avatar, name: '' }, + }) + + expect(source).not.toContain('') + expect(source).toContain('</title>') + expect(source).toContain('\\u003c/script>') + }) + + it('packages the direct-open demo and reusable module together', async () => { + const archive = new Uint8Array(await generateJavaScriptAvatarPackage(payload).arrayBuffer()) + const contents = new TextDecoder().decode(archive) + + expect(contents).toContain('index.html') + expect(contents).toContain('avatar.js') + }) + + it('generates a typed React component backed by the local runtime', () => { + const source = generateReactAvatarComponent(payload) + + expect(source).toContain("from 'react'") + expect(source).toContain("from './avatar-runtime'") + expect(source).toContain("from './strobi.avatar'") + expect(source).toContain('export type { AnimationName }') + expect(source).toContain('animation?: AnimationName') + expect(source).toContain('forwardRef') + expect(source).not.toContain('AvatarProceduralEngine') + expect(source).toContain('runtime.createAvatar(host.current') + expect(source).not.toContain('@ts-nocheck') + expect(source).not.toContain('avatarFrameIn') + expect(source).not.toContain("from 'motion") + expect(() => + parse(source, { sourceType: 'module', plugins: ['typescript', 'jsx'] }) + ).not.toThrow() + }) + + it('packages the React runtime, avatar data and component separately', async () => { + const archive = new Uint8Array(await generateReactAvatarPackage(payload).arrayBuffer()) + const contents = new TextDecoder().decode(archive) + + expect(contents).toContain('avatar-runtime.ts') + expect(contents).toContain('strobi.avatar.ts') + expect(contents).toContain('Strobi.tsx') + expect(contents).toContain('strobi.index.ts') + expect(contents).toContain("from './avatar-runtime'") + }) + + it('generates one avatar-independent and versioned React runtime', () => { + const runtime = generateReactAvatarRuntime() + + expect(runtime).toContain('AVATAR_RUNTIME_VERSION = 1') + expect(runtime).toContain('data.version !== AVATAR_RUNTIME_VERSION') + expect(runtime).not.toContain(payload.avatar.name) + expect(runtime).toContain('RuntimeAvatar') + expect(() => parse(runtime, { sourceType: 'module', plugins: ['typescript'] })).not.toThrow() + }) + + it('uses globally unique SVG identifiers across avatar runtimes', () => { + const source = generateJavaScriptAvatarModule(payload) + + expect(source).toContain('globalThis.crypto.randomUUID()') + expect(source).not.toContain('avatarInstanceCount') + }) +}) diff --git a/src/__tests__/expression-editing-test.ts b/src/__tests__/expression-editing-test.ts new file mode 100644 index 0000000..88c3567 --- /dev/null +++ b/src/__tests__/expression-editing-test.ts @@ -0,0 +1,24 @@ +import { updateEyeDimension, scaleEye, updateEyePosition } from '../expressionEditing' +import { defaultExpression } from '../presets' + +describe('Expression eye editing', () => { + it('applies linked dimensions through one invariant-preserving operation', () => { + const next = updateEyeDimension(defaultExpression, 'Left', 'width', 140, true) + expect(next.widthLeft).toBe(100) + expect(next.widthRight).toBe(100) + }) + + it('scales both eyes proportionally without exceeding eye bounds', () => { + const next = scaleEye(defaultExpression, 'Left', 100, true) + expect(next.heightLeft).toBe(100) + expect(next.widthLeft).toBe(40) + expect(next.heightRight).toBe(100) + expect(next.widthRight).toBe(40) + }) + + it('keeps linked eye positions symmetrical as an editing rule', () => { + const next = updateEyePosition(defaultExpression, 'Right', 'Y', 12, true) + expect(next.positionYLeft).toBe(12) + expect(next.positionYRight).toBe(12) + }) +}) diff --git a/src/__tests__/geometry-editor-test.ts b/src/__tests__/geometry-editor-test.ts new file mode 100644 index 0000000..dbe65e9 --- /dev/null +++ b/src/__tests__/geometry-editor-test.ts @@ -0,0 +1,91 @@ +import type { BodyNode } from '../body' +import { + poseFromExpression, + renderAvatar, + renderBodyNodeEditor, + rotateBodyNodeAroundLocalAxis, + translateBodyNodeInCameraPlane, + translateBodyNodeAlongLocalAxis, +} from '../geometry' +import { defaultExpression } from '../presets' +import { surfacePresets } from '../surfaces' + +const node: BodyNode = { + id: 'shape-test', + name: 'Forme test', + surface: surfacePresets.cube, + position: [0, 0, -20], + rotation: [0, 0, 0], +} + +describe('body node editor geometry', () => { + it('keeps all translation axes manipulable when one points at the camera', () => { + const editor = renderBodyNodeEditor(poseFromExpression(defaultExpression), node) + + expect( + Math.hypot(editor.axes.z[0] - editor.center[0], editor.axes.z[1] - editor.center[1]) + ).toBeGreaterThanOrEqual(12) + expect(Object.values(editor.axes).flat().every(Number.isFinite)).toBe(true) + }) + + it('projects complete local rotation rings around the selected node', () => { + const editor = renderBodyNodeEditor(poseFromExpression(defaultExpression), { + ...node, + rotation: [20, -15, 35], + }) + + expect(editor.rings.x).toHaveLength(65) + expect(editor.rings.y).toHaveLength(65) + expect(editor.rings.z).toHaveLength(65) + expect(editor.rings.z[0]).not.toEqual(editor.rings.z[16]) + }) + + it('moves along the rotated local axis', () => { + const translated = translateBodyNodeAlongLocalAxis({ ...node, rotation: [0, 0, 90] }, 'x', 20) + + expect(translated.position[0]).toBeCloseTo(0) + expect(translated.position[1]).toBeCloseTo(20) + expect(translated.position[2]).toBeCloseTo(-20) + }) + + it('moves freely in the camera plane regardless of head rotation', () => { + const pose = poseFromExpression({ ...defaultExpression, headX: 24, headY: -31, headZ: 18 }) + const before = renderBodyNodeEditor(pose, node).center + const translated = translateBodyNodeInCameraPlane(node, pose, 18, -9) + const after = renderBodyNodeEditor(pose, translated).center + + expect(after[0] - before[0]).toBeCloseTo(18) + expect(after[1] - before[1]).toBeCloseTo(-9) + expect(translated.position[2]).not.toBe(node.position[2]) + }) + + it('composes a local rotation instead of incrementing a raw Euler field', () => { + const rotated = rotateBodyNodeAroundLocalAxis({ ...node, rotation: [25, 30, -20] }, 'x', 15) + + expect(rotated.rotation.every(Number.isFinite)).toBe(true) + expect(rotated.rotation).not.toEqual([40, 30, -20]) + }) + + it('keeps equivalent Euler values near their current free rotation', () => { + const rotated = rotateBodyNodeAroundLocalAxis({ ...node, rotation: [0, 0, 350] }, 'x', 1) + + expect(rotated.rotation[2]).toBeGreaterThan(300) + }) + + it('keeps a spherical silhouette path stable across head rotations', () => { + const neutralPath = renderAvatar( + poseFromExpression(defaultExpression), + surfacePresets.sphere, + 1, + { includeWire: false } + ).headPath + const rotatedPath = renderAvatar( + poseFromExpression({ ...defaultExpression, headX: 17, headY: 34, headZ: -21 }), + surfacePresets.sphere, + 1, + { includeWire: false } + ).headPath + + expect(rotatedPath).toBe(neutralPath) + }) +}) diff --git a/src/__tests__/i18n-test.ts b/src/__tests__/i18n-test.ts new file mode 100644 index 0000000..f68e0cf --- /dev/null +++ b/src/__tests__/i18n-test.ts @@ -0,0 +1,45 @@ +import { translateStudioText } from '../i18n' + +describe('avatar studio translations', () => { + it('uses English for static interface copy', () => { + expect(translateStudioText('Couleur des yeux', 'en')).toBe('Eye color') + }) + + it('translates dynamic editor labels', () => { + expect(translateStudioText('Modifier l’expression 08', 'en')).toBe('Edit expression 08') + expect(translateStudioText('Sphère · porte les yeux', 'en')).toBe('Sphere · carries the eyes') + expect(translateStudioText('Cône 1 copie', 'en')).toBe('Cone 1 copy') + }) + + it('keeps the original French copy in French', () => { + expect(translateStudioText('Nouvel avatar', 'fr')).toBe('Nouvel avatar') + expect(translateStudioText('sleeping', 'fr')).toBe('sommeil') + }) + + it('translates the studio interface and dynamic labels to Simplified Chinese', () => { + expect(translateStudioText('Couleur des yeux', 'zh-CN')).toBe('眼睛颜色') + expect(translateStudioText('Modifier l’expression 08', 'zh-CN')).toBe('编辑表情 08') + expect(translateStudioText('sleeping', 'zh-CN')).toBe('睡眠') + }) + + it('translates every Photo Mode control to Simplified Chinese', () => { + expect(translateStudioText('Mode photo', 'zh-CN')).toBe('照片模式') + expect(translateStudioText('Dégradé radial', 'zh-CN')).toBe('径向渐变') + expect(translateStudioText('Définition du mode photo', 'zh-CN')).toBe('照片模式分辨率') + expect(translateStudioText('Télécharger en PNG', 'zh-CN')).toBe('下载 PNG') + expect(translateStudioText('Prendre une photo', 'zh-CN')).toBe('拍照') + expect(translateStudioText('Format d’export', 'zh-CN')).toBe('导出格式') + }) + + it('covers every configured state description in English', () => { + expect(translateStudioText('Rythme régulier et expressions concentrées.', 'en')).toBe( + 'Steady rhythm and focused expressions.' + ) + expect(translateStudioText('Grandes expressions et transitions rapides.', 'en')).toBe( + 'Big expressions and fast transitions.' + ) + expect(translateStudioText('Inclinaisons et forte asymétrie.', 'en')).toBe( + 'Tilts and strong asymmetry.' + ) + }) +}) diff --git a/src/__tests__/manipulation-session-test.ts b/src/__tests__/manipulation-session-test.ts new file mode 100644 index 0000000..7b8d4d4 --- /dev/null +++ b/src/__tests__/manipulation-session-test.ts @@ -0,0 +1,47 @@ +import { + beginManipulation, + beginManipulationFromRenderedValue, + finishManipulation, + previewManipulation, +} from '../manipulationSession' + +describe('pointer manipulation session', () => { + it('previews many values and commits exactly once', () => { + const previews: number[] = [] + const commits: number[] = [] + const session = beginManipulation(10) + previewManipulation(session, 11, value => previews.push(value)) + previewManipulation(session, 12, value => previews.push(value)) + + finishManipulation(session, 'commit', { + preview: value => previews.push(value), + commit: value => commits.push(value), + }) + + expect(previews).toEqual([11, 12]) + expect(commits).toEqual([12]) + }) + + it('restores the initial value when pointer capture is cancelled', () => { + const previews: number[] = [] + const session = beginManipulation(10) + previewManipulation(session, 20, () => undefined) + + finishManipulation(session, 'cancel', { + preview: value => previews.push(value), + commit: () => undefined, + }) + + expect(previews).toEqual([10]) + }) + + it('starts from the live rendered value while an interpolation is running', () => { + const staleInspectorValue = { headX: 0, headY: 0 } + const renderedMidTransition = { headX: 18, headY: 27 } + + const session = beginManipulationFromRenderedValue(() => renderedMidTransition) + + expect(session.initial).toBe(renderedMidTransition) + expect(session.initial).not.toBe(staleInspectorValue) + }) +}) diff --git a/src/__tests__/playback-test.ts b/src/__tests__/playback-test.ts new file mode 100644 index 0000000..a6fe56a --- /dev/null +++ b/src/__tests__/playback-test.ts @@ -0,0 +1,43 @@ +import { createInitialSequences } from '../sequences' +import { + advancePlaybackTimeline, + beginPlayback, + createPlaybackTimeline, + pausePlaybackTimeline, + schedulePlaybackBlink, + schedulePlaybackStep, +} from '../playback' + +describe('State playback timeline', () => { + it('preserves the current sequence position when the avatar changes', () => { + const timeline = createPlaybackTimeline() + timeline.position = 1 + timeline.direction = -1 + + const preserved = beginPlayback(timeline, true) + + expect(preserved.position).toBe(1) + expect(preserved.direction).toBe(-1) + }) + + it('pauses and resumes from the exact remaining delays', () => { + const timeline = createPlaybackTimeline() + const scheduledStep = schedulePlaybackStep(timeline, 1_000, 500) + const scheduled = schedulePlaybackBlink(scheduledStep, 1_000, 900) + + const paused = pausePlaybackTimeline(scheduled, 1_200) + + expect(paused.stepRemainingMs).toBe(300) + expect(paused.blinkRemainingMs).toBe(700) + }) + + it('owns cursor advancement for the active State', () => { + const idle = createInitialSequences().find(sequence => sequence.id === 'idle')! + const timeline = createPlaybackTimeline() + + const result = advancePlaybackTimeline(timeline, idle) + + expect(result.cursor.complete).toBe(false) + expect(result.timeline.position).toBe(1) + }) +}) diff --git a/src/__tests__/presets-test.ts b/src/__tests__/presets-test.ts new file mode 100644 index 0000000..563884e --- /dev/null +++ b/src/__tests__/presets-test.ts @@ -0,0 +1,26 @@ +import { getStatePlaybackConfig } from '../presets' + +describe('state playback configuration', () => { + it('keeps idle slower than an active sequence', () => { + expect(getStatePlaybackConfig('idle').expressionIntervalMs).toBe(5200) + expect(getStatePlaybackConfig('listening').expressionIntervalMs).toBe(2300) + }) + + it('describes a valid randomized blink interval', () => { + const { blink } = getStatePlaybackConfig('idle') + + expect(blink.initialDelayMs).toBeGreaterThan(0) + expect(blink.minIntervalMs).toBeLessThan(blink.maxIntervalMs) + expect(blink.durationMs).toBe(280) + }) + + it('uses a blink rhythm adapted to each sequence family', () => { + const sleeping = getStatePlaybackConfig('sleeping').blink + const listening = getStatePlaybackConfig('listening').blink + const excited = getStatePlaybackConfig('excited').blink + + expect(sleeping.minIntervalMs).toBeGreaterThan(listening.minIntervalMs) + expect(listening.minIntervalMs).toBeGreaterThan(excited.minIntervalMs) + expect(sleeping.durationMs).toBeGreaterThan(excited.durationMs) + }) +}) diff --git a/src/__tests__/rendered-rotation-gizmo-test.ts b/src/__tests__/rendered-rotation-gizmo-test.ts new file mode 100644 index 0000000..5ca3274 --- /dev/null +++ b/src/__tests__/rendered-rotation-gizmo-test.ts @@ -0,0 +1,20 @@ +import { defaultExpression } from '../presets' +import { createRenderedRotationGizmo, paintRenderedRotationGizmo } from '../renderedRotationGizmo' + +describe('rendered rotation gizmo', () => { + it('updates its rings without requiring a React render', () => { + const gizmo = createRenderedRotationGizmo(defaultExpression) + const initialXPath = gizmo.xPath.get() + const initialYPath = gizmo.yPath.get() + + paintRenderedRotationGizmo(gizmo, { + ...defaultExpression, + headX: 28, + headY: -36, + headZ: 17, + }) + + expect(gizmo.xPath.get()).not.toBe(initialXPath) + expect(gizmo.yPath.get()).not.toBe(initialYPath) + }) +}) diff --git a/src/__tests__/rendered-scene-test.ts b/src/__tests__/rendered-scene-test.ts new file mode 100644 index 0000000..8bc3525 --- /dev/null +++ b/src/__tests__/rendered-scene-test.ts @@ -0,0 +1,72 @@ +import { createBodyNode } from '../body' +import { renderAvatar, poseFromExpression } from '../geometry' +import { defaultExpression } from '../presets' +import { + createRenderedColors, + createRenderedScene, + findBodyNodePath, + paintRenderedColors, + paintRenderedScene, +} from '../renderedScene' +import { surfacePresets } from '../surfaces' +import defaultStudioDocument from '../defaultStudioDocument.json' +import type { BodyNode } from '../body' +import type { Expression } from '../geometry' +import type { SurfaceConfig } from '../surfaces' + +describe('rendered avatar scene', () => { + it('keeps layer identity and hit mapping behind the scene seam', () => { + const node = createBodyNode('sphere', 0) + const first = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1, { + bodyNodes: [node], + }) + const scene = createRenderedScene(first) + const rotated = renderAvatar( + poseFromExpression({ ...defaultExpression, headY: 35 }), + surfacePresets.sphere, + 1, + { bodyNodes: [node] } + ) + + paintRenderedScene(scene, rotated) + + expect(findBodyNodePath(scene, 'primary')).toBe(scene.headPath) + expect(findBodyNodePath(scene, node.id)).not.toBeNull() + expect(scene.headPath.get()).toBe(rotated.headPath) + }) + + it('updates animated colors without replacing their motion values', () => { + const colors = createRenderedColors({ body: '#5b7fe5', eyes: '#111316' }) + const body = colors.body + const eyes = colors.eyes + + paintRenderedColors(colors, { body: '#c53b47', eyes: '#ffffff' }) + + expect(colors.body).toBe(body) + expect(colors.eyes).toBe(eyes) + expect(colors.body.get()).toBe('#c53b47') + expect(colors.eyes.get()).toBe('#ffffff') + }) + + it('keeps Cloudee accessories behind the eyes at expression position 05', () => { + const avatar = defaultStudioDocument.library.avatars.find(item => item.name === 'Cloudee')! + const expression = defaultStudioDocument.expressions[5] as Expression + + const geometry = renderAvatar( + poseFromExpression(expression), + avatar.body.primary as SurfaceConfig, + 1, + { bodyNodes: avatar.body.nodes as BodyNode[] } + ) + + expect(geometry.frontNodeIds).toEqual([]) + + const clearlyTurned = renderAvatar( + poseFromExpression({ ...expression, headY: -35 }), + avatar.body.primary as SurfaceConfig, + 1, + { bodyNodes: avatar.body.nodes as BodyNode[] } + ) + expect(clearlyTurned.frontNodeIds).toContain('shape-d4b4e8ad-8625-488d-920c-c497da226f9f') + }) +}) diff --git a/src/__tests__/sequences-test.ts b/src/__tests__/sequences-test.ts new file mode 100644 index 0000000..6c5b694 --- /dev/null +++ b/src/__tests__/sequences-test.ts @@ -0,0 +1,97 @@ +import { + advanceSequenceCursor, + createInitialSequences, + getSequenceSpring, + normalizeSequencesForExpressions, + parseSequences, + remapSequencesAfterExpressionDelete, +} from '../sequences' +import { initialExpressions } from '../presets' + +describe('editable avatar sequences', () => { + it('creates the idle animation with editable steps and blink settings', () => { + const idle = createInitialSequences().find(sequence => sequence.id === 'idle') + + expect(idle?.steps.map(step => step.expressionId)).toEqual([ + initialExpressions[0].id, + initialExpressions[8].id, + ]) + expect(idle?.steps[0].holdMs).toBe(5200) + expect(idle?.blink.durationMs).toBe(280) + }) + + it('supports loop, once and ping-pong playback cursors', () => { + const base = createInitialSequences().find(sequence => sequence.id === 'listening')! + + expect(advanceSequenceCursor({ ...base, playbackMode: 'loop' }, 2, 1)).toEqual({ + index: 0, + direction: 1, + complete: false, + }) + expect(advanceSequenceCursor({ ...base, playbackMode: 'once' }, 2, 1).complete).toBe(true) + expect(advanceSequenceCursor({ ...base, playbackMode: 'pingPong' }, 2, 1)).toEqual({ + index: 1, + direction: -1, + complete: false, + }) + }) + + it('keeps sequence references stable when expressions are reordered', () => { + const sequence = createInitialSequences().find(item => item.id === 'idle')! + const reordered = [...initialExpressions].reverse() + const [normalized] = normalizeSequencesForExpressions([sequence], reordered) + + expect(normalized.steps.map(step => step.expressionId)).toEqual( + sequence.steps.map(step => step.expressionId) + ) + }) + + it('keeps a sequence playable when its only referenced expression is deleted', () => { + const sequence = createInitialSequences().find(item => item.id === 'waking')! + const fallbackId = initialExpressions[12].id + const [remapped] = remapSequencesAfterExpressionDelete( + [sequence], + initialExpressions[13].id, + fallbackId + ) + + expect(remapped.steps).toHaveLength(1) + expect(remapped.steps[0].expressionId).toBe(fallbackId) + }) + + it('sanitizes persisted timing values and invalid playback values', () => { + const [sequence] = parseSequences([ + { + id: 'custom', + name: 'Custom', + playbackMode: 'invalid', + steps: [{ expressionId: initialExpressions[2].id, holdMs: -5, transitionMs: 99999 }], + blink: { minIntervalMs: 9000, maxIntervalMs: 1000, durationMs: 2 }, + }, + ]) + + expect(sequence.playbackMode).toBe('loop') + expect(sequence.steps[0].holdMs).toBe(100) + expect(sequence.steps[0].transitionMs).toBe(5000) + expect(sequence.blink.maxIntervalMs).toBe(sequence.blink.minIntervalMs) + expect(sequence.blink.durationMs).toBe(40) + }) + + it('repairs missing and out-of-range expression references on load', () => { + const sequence = createInitialSequences().find(item => item.id === 'idle')! + const [normalized] = normalizeSequencesForExpressions( + [{ ...sequence, steps: [{ ...sequence.steps[0], expressionId: 'missing' }] }], + initialExpressions.slice(0, 4) + ) + + expect(normalized.steps[0].expressionId).toBe(initialExpressions[0].id) + }) + + it('maps transition styles and durations to distinct spring dynamics', () => { + const smooth = getSequenceSpring('smooth', 900, 7) + const snappy = getSequenceSpring('snappy', 250, 7) + + expect(snappy.stiffness).toBeGreaterThan(smooth.stiffness) + expect(smooth.damping).toBeGreaterThan(0) + }) +}) diff --git a/src/__tests__/snapshot-exporter-test.ts b/src/__tests__/snapshot-exporter-test.ts new file mode 100644 index 0000000..ba2fb8c --- /dev/null +++ b/src/__tests__/snapshot-exporter-test.ts @@ -0,0 +1,41 @@ +import { poseFromExpression, renderAvatar } from '../geometry' +import { defaultExpression } from '../presets' +import { createRenderedScene, paintRenderedOffset } from '../renderedScene' +import { serializeAvatarSnapshot, snapshotFileName } from '../snapshotExporter' +import { surfacePresets } from '../surfaces' + +describe('avatar snapshot export', () => { + const geometry = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1) + const scene = createRenderedScene(geometry) + const colors = { body: '#5b7fe5', eyes: '#111316' } + + it('exports the currently rendered scene as a transparent SVG', () => { + paintRenderedOffset(scene, { x: 3, y: -2 }) + const svg = serializeAvatarSnapshot('Strobi', scene, colors, { + background: 'transparent', + colorFrom: '#ffffff', + colorTo: '#000000', + size: 1024, + }) + + expect(svg).toContain('width="1024" height="1024"') + expect(svg).toContain('transform="translate(3 -2)"') + expect(svg).toContain(`d="${geometry.headPath}" fill="#5b7fe5"`) + expect(svg).toContain('fill="#111316"') + expect(svg).not.toContain(' { + const svg = serializeAvatarSnapshot('Strobi', scene, colors, { + background: 'radial', + colorFrom: '#ffffff', + colorTo: '#8899aa', + size: 512, + }) + + expect(svg).toContain(' { + const avatar = createAvatar('Strobi') + return { + version: 2, + library: { + activeAvatarId: avatar.id, + avatars: [avatar], + }, + expressions: initialExpressions, + sequences: createInitialSequences(), + playback: { stateId: 'idle', playing: true }, + } +} + +describe('Studio document', () => { + const storage = (value: string | null = null) => ({ getItem: () => value }) + + it('loads the bundled Studio snapshot when no local project exists', () => { + const document = loadStudioDocument(storage()) + + expect(document.library.avatars).toHaveLength(10) + expect(document.library.activeAvatarId).toBe(document.library.avatars[0].id) + expect(document.library.avatars[0].name).toBe('Strobi') + expect(document.expressions).toHaveLength(27) + expect(document.sequences).toHaveLength(23) + expect(document.playback).toEqual({ stateId: 'proud', playing: true }) + }) + + it('keeps a locally saved project authoritative over the bundled snapshot', () => { + const localDocument = documentFixture() + + expect(loadStudioDocument(storage(JSON.stringify(localDocument)))).toEqual(localDocument) + }) + + it('persists one coherent document after a mutation', () => { + const persisted: StudioDocument[] = [] + const store = createStudioDocumentStore(documentFixture(), value => persisted.push(value)) + + store.update({ playback: { stateId: 'idle', playing: false } }) + + expect(persisted).toHaveLength(1) + expect(persisted[0].playback).toEqual({ stateId: 'idle', playing: false }) + expect(persisted[0].expressions).toHaveLength(initialExpressions.length) + }) + + it('repairs sequence references in the same transaction as expression deletion', () => { + const store = createStudioDocumentStore(documentFixture(), () => undefined) + const remainingExpressions = initialExpressions.slice(1) + + const next = store.update({ expressions: remainingExpressions }) + + expect( + next.sequences.every(sequence => + sequence.steps.every(step => + remainingExpressions.some(item => item.id === step.expressionId) + ) + ) + ).toBe(true) + }) + + it('round-trips a complete project document as portable JSON', () => { + const document = documentFixture() + const expression = { ...initialExpressions[0], widthLeft: 42 } + const sequence = { + ...createInitialSequences()[0], + steps: createInitialSequences()[0].steps.map(step => ({ + ...step, + expressionId: expression.id, + })), + } + document.library.avatars[0].behavior = { + expressions: [expression], + sequences: [sequence], + } + + const imported = parseImportedStudioDocument(serializeStudioDocument(document), document) + + expect(imported).toEqual(document) + expect(imported.library.avatars[0].behavior?.expressions[0].widthLeft).toBe(42) + }) + + it('keeps the base library unchanged when an avatar owns customized behavior', () => { + const document = documentFixture() + const avatar = document.library.avatars[0] + const customized = { + ...avatar, + behavior: { + expressions: [{ ...initialExpressions[0], widthLeft: 47 }], + sequences: createInitialSequences().slice(0, 1), + }, + } + const store = createStudioDocumentStore(document, () => undefined) + + const next = store.update({ + library: { activeAvatarId: avatar.id, avatars: [customized] }, + }) + + expect(next.expressions[0].widthLeft).toBe(initialExpressions[0].widthLeft) + expect(next.library.avatars[0].behavior?.expressions[0].widthLeft).toBe(47) + }) + + it('rejects files that are not versioned Studio projects', () => { + const fallback = documentFixture() + + expect(() => parseImportedStudioDocument('{"version":1}', fallback)).toThrow( + 'Unsupported Avatar Studio project' + ) + expect(() => parseImportedStudioDocument('{broken', fallback)).toThrow( + 'Invalid Avatar Studio project' + ) + }) + + it('repairs an imported active avatar and missing expression references', () => { + const fallback = documentFixture() + const avatar = createAvatar('Portable') + const imported = parseImportedStudioDocument( + JSON.stringify({ + ...fallback, + library: { activeAvatarId: 'missing', avatars: [avatar] }, + sequences: [ + { + ...createInitialSequences()[0], + steps: [{ ...createInitialSequences()[0].steps[0], expressionId: 'missing' }], + }, + ], + }), + fallback + ) + + expect(imported.library.activeAvatarId).toBe(avatar.id) + expect(imported.sequences[0].steps[0].expressionId).toBe(imported.expressions[0].id) + }) + + it('sanitizes imported animation timing and playback values', () => { + const fallback = documentFixture() + const imported = parseImportedStudioDocument( + JSON.stringify({ + ...fallback, + sequences: [ + { + ...fallback.sequences[0], + playbackMode: 'unsupported', + steps: [ + { + ...fallback.sequences[0].steps[0], + holdMs: -500, + transitionMs: Number.POSITIVE_INFINITY, + }, + ], + }, + ], + }), + fallback + ) + + expect(imported.sequences[0].playbackMode).toBe('loop') + expect(imported.sequences[0].steps[0].holdMs).toBeGreaterThanOrEqual(100) + expect(Number.isFinite(imported.sequences[0].steps[0].transitionMs)).toBe(true) + }) +}) diff --git a/src/ambientMotion.ts b/src/ambientMotion.ts new file mode 100644 index 0000000..2e392a0 --- /dev/null +++ b/src/ambientMotion.ts @@ -0,0 +1,96 @@ +import type { BodyMotion, Expression, EyeMotion } from './geometry' + +export const eyeMotionModes = ['none', 'microSaccades', 'shake'] as const +export const bodyMotionModes = ['none', 'slowDrift', 'shake'] as const +const eyeMotionSet = new Set(eyeMotionModes) +const bodyMotionSet = new Set(bodyMotionModes) +export const isEyeMotion = (value: unknown): value is EyeMotion => + typeof value === 'string' && eyeMotionSet.has(value) +export const isBodyMotion = (value: unknown): value is BodyMotion => + typeof value === 'string' && bodyMotionSet.has(value) + +const smoothstep = (value: number) => value * value * (3 - 2 * value) +const hash = (value: number) => { + const raw = Math.sin(value * 127.1 + 311.7) * 43758.5453 + return (raw - Math.floor(raw)) * 2 - 1 +} + +const expressionSeed = (expression: Expression) => + expression.headX * 0.71 + expression.headY * 1.13 + expression.headZ * 1.37 + +const smoothNoise = (elapsedMs: number, axis: number, seed: number, interval: number) => { + const progress = elapsedMs / interval + const step = Math.floor(progress) + const blend = smoothstep(progress - step) + const previous = hash(step * 3 + axis + seed) + const next = hash((step + 1) * 3 + axis + seed) + return previous + (next - previous) * blend +} + +const saccade = (elapsedMs: number, axis: number, seed: number) => { + const interval = 1100 + const warpedTime = elapsedMs + Math.sin(elapsedMs / 1700 + seed) * 280 + const step = Math.floor(warpedTime / interval) + const progress = (((warpedTime % interval) + interval) % interval) / interval + const blend = smoothstep(Math.min(progress / 0.09, 1)) + const previous = hash((step - 1) * 2 + axis + seed) + const next = hash(step * 2 + axis + seed) + const fadeIn = Math.min(elapsedMs / 240, 1) + return (previous + (next - previous) * blend) * fadeIn +} + +export const hasAmbientMotion = (expression: Expression) => + expression.eyeMotion !== 'none' || expression.bodyMotion !== 'none' + +export const ambientBodyOffset = (expression: Expression, elapsedMs: number) => { + const seed = expressionSeed(expression) + if (expression.bodyMotion === 'slowDrift') { + return { + x: smoothNoise(elapsedMs, 3, seed, 2900) * 1.45, + y: smoothNoise(elapsedMs, 4, seed, 3700) * 1.1, + } + } + if (expression.bodyMotion === 'shake') { + const time = elapsedMs / 1000 + return { + x: (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.35, + y: (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.1, + } + } + return { x: 0, y: 0 } +} + +export const applyAmbientMotion = (expression: Expression, elapsedMs: number): Expression => { + const next = { ...expression } + const seed = expressionSeed(expression) + + if (expression.eyeMotion === 'microSaccades') { + const x = saccade(elapsedMs, 0, seed) * 1.5 + const y = saccade(elapsedMs, 1, seed) * 0.9 + next.positionXLeft += x + next.positionXRight += x + next.positionYLeft += y + next.positionYRight += y + } else if (expression.eyeMotion === 'shake') { + const time = elapsedMs / 1000 + const x = (Math.sin(time * 47) + Math.sin(time * 71) * 0.45) * 1.2 + const y = (Math.sin(time * 59) + Math.sin(time * 83) * 0.4) * 0.8 + next.positionXLeft += x + next.positionXRight += x + next.positionYLeft += y + next.positionYRight += y + } + + if (expression.bodyMotion === 'slowDrift') { + next.headX += smoothNoise(elapsedMs, 0, seed, 2600) * 0.8 + next.headY += smoothNoise(elapsedMs, 1, seed, 3300) * 1.15 + next.headZ += smoothNoise(elapsedMs, 2, seed, 4100) * 0.45 + } else if (expression.bodyMotion === 'shake') { + const time = elapsedMs / 1000 + next.headX += (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.15 + next.headY += (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.35 + next.headZ += Math.sin(time * 43) * 0.7 + } + + return next +} diff --git a/src/avatars.ts b/src/avatars.ts new file mode 100644 index 0000000..4d1ff7f --- /dev/null +++ b/src/avatars.ts @@ -0,0 +1,211 @@ +import { parseAvatarBody, type AvatarBody } from './body' +import { defaultExpression, initialExpressions } from './presets' +import { surfacePresets } from './surfaces' +import type { Expression } from './geometry' +import { isBodyMotion, isEyeMotion } from './ambientMotion' +import { normalizeSequencesForExpressions, parseSequences, type AvatarSequence } from './sequences' + +export type AvatarBehaviorLibrary = { + expressions: Expression[] + sequences: AvatarSequence[] +} + +export type StudioAvatar = { + id: string + name: string + body: AvatarBody + colors: AvatarColors + eyes: AvatarEyeDefaults + behavior?: AvatarBehaviorLibrary +} + +export type AvatarColors = { body: string; eyes: string } +export type AvatarEyeDefaults = Pick< + Expression, + | 'widthLeft' + | 'widthRight' + | 'heightLeft' + | 'heightRight' + | 'spacing' + | 'positionXLeft' + | 'positionXRight' + | 'positionYLeft' + | 'positionYRight' + | 'leftAngle' + | 'rightAngle' +> +export const defaultAvatarColors: AvatarColors = { body: '#5b7fe5', eyes: '#111316' } +export const defaultAvatarEyes: AvatarEyeDefaults = { + widthLeft: defaultExpression.widthLeft, + widthRight: defaultExpression.widthRight, + heightLeft: defaultExpression.heightLeft, + heightRight: defaultExpression.heightRight, + spacing: defaultExpression.spacing, + positionXLeft: defaultExpression.positionXLeft, + positionXRight: defaultExpression.positionXRight, + positionYLeft: defaultExpression.positionYLeft, + positionYRight: defaultExpression.positionYRight, + leftAngle: defaultExpression.leftAngle, + rightAngle: defaultExpression.rightAngle, +} +const hexColor = /^#[0-9a-f]{6}$/i +const parseColors = (value: unknown): AvatarColors => { + const candidate = value as Partial | null + return { + body: + typeof candidate?.body === 'string' && hexColor.test(candidate.body) + ? candidate.body + : defaultAvatarColors.body, + eyes: + typeof candidate?.eyes === 'string' && hexColor.test(candidate.eyes) + ? candidate.eyes + : defaultAvatarColors.eyes, + } +} + +const eyeDefaultFields = Object.keys(defaultAvatarEyes) as (keyof AvatarEyeDefaults)[] +export const parseAvatarEyeDefaults = (value: unknown): AvatarEyeDefaults => { + const candidate = value as Partial | null + const parsed = { ...defaultAvatarEyes } + eyeDefaultFields.forEach(field => { + const stored = candidate?.[field] + if (typeof stored === 'number' && Number.isFinite(stored)) parsed[field] = stored + }) + return parsed +} + +export const applyAvatarEyeDefaults = ( + expression: Expression, + eyes: AvatarEyeDefaults = defaultAvatarEyes +): Expression => { + const result = { ...expression } + eyeDefaultFields.forEach(field => { + result[field] = expression[field] + eyes[field] - defaultAvatarEyes[field] + }) + result.widthLeft = Math.max(10, result.widthLeft) + result.widthRight = Math.max(10, result.widthRight) + result.heightLeft = Math.max(10, result.heightLeft) + result.heightRight = Math.max(10, result.heightRight) + return result +} + +export type AvatarLibrary = { + activeAvatarId: string + avatars: StudioAvatar[] +} + +const cloneExpressions = (expressions: Expression[]) => expressions.map(item => ({ ...item })) +export const parseExpressions = (value: unknown): Expression[] => { + if (!Array.isArray(value) || !value.length) return cloneExpressions(initialExpressions) + return value.map((item, index) => { + if (!item || typeof item !== 'object') { + return { ...defaultExpression, id: `expression-${String(index).padStart(2, '0')}` } + } + const candidate = item as Partial + const storedEyeMotion = (item as { eyeMotion?: unknown }).eyeMotion + const storedBodyMotion = (item as { bodyMotion?: unknown }).bodyMotion + const parsed = Object.fromEntries( + Object.entries(defaultExpression).map(([field, fallback]) => { + if (field === 'id') { + return [ + field, + typeof candidate.id === 'string' && candidate.id + ? candidate.id + : `expression-${String(index).padStart(2, '0')}`, + ] + } + const stored = candidate[field as keyof Expression] + return [field, typeof stored === 'number' && Number.isFinite(stored) ? stored : fallback] + }) + ) as Expression + if (typeof candidate.bodyColor === 'string' && hexColor.test(candidate.bodyColor)) + parsed.bodyColor = candidate.bodyColor + if (typeof candidate.eyeColor === 'string' && hexColor.test(candidate.eyeColor)) + parsed.eyeColor = candidate.eyeColor + parsed.eyeMotion = isEyeMotion(storedEyeMotion) ? storedEyeMotion : defaultExpression.eyeMotion + parsed.bodyMotion = isBodyMotion(storedBodyMotion) + ? storedBodyMotion + : defaultExpression.bodyMotion + return parsed + }) +} + +const cloneSequences = (sequences: AvatarSequence[]) => + sequences.map(sequence => ({ + ...sequence, + steps: sequence.steps.map(step => ({ ...step })), + blink: { ...sequence.blink }, + })) + +export const cloneAvatarBehavior = (behavior: AvatarBehaviorLibrary): AvatarBehaviorLibrary => ({ + expressions: cloneExpressions(behavior.expressions), + sequences: cloneSequences(behavior.sequences), +}) + +export const resolveAvatarBehavior = ( + avatar: StudioAvatar, + base: AvatarBehaviorLibrary +): AvatarBehaviorLibrary => avatar.behavior ?? base + +const parseAvatarBehavior = ( + value: unknown, + base: AvatarBehaviorLibrary +): AvatarBehaviorLibrary | undefined => { + if (!value || typeof value !== 'object') return undefined + const candidate = value as Partial + if (!Array.isArray(candidate.expressions) || !candidate.expressions.length) return undefined + const expressions = parseExpressions(candidate.expressions) + const sequences = normalizeSequencesForExpressions( + Array.isArray(candidate.sequences) + ? parseSequences(candidate.sequences) + : cloneSequences(base.sequences), + expressions + ) + return { expressions, sequences } +} + +export const createAvatar = (name: string): StudioAvatar => ({ + id: `avatar-${crypto.randomUUID()}`, + name: name.trim() || 'Nouvel avatar', + body: { primary: { ...surfacePresets.sphere }, nodes: [] }, + colors: { ...defaultAvatarColors }, + eyes: { ...defaultAvatarEyes }, +}) + +export const parseAvatarLibrary = ( + value: unknown, + fallback: AvatarLibrary, + baseBehavior: AvatarBehaviorLibrary +): AvatarLibrary => { + try { + const parsed = value as Partial | null + if (!parsed || !Array.isArray(parsed.avatars) || !parsed.avatars.length) return fallback + const seenIds = new Set() + const avatars = parsed.avatars + .filter(avatar => { + if (!avatar || typeof avatar.id !== 'string' || typeof avatar.name !== 'string') + return false + if (seenIds.has(avatar.id)) return false + seenIds.add(avatar.id) + return true + }) + .map(avatar => { + const behavior = parseAvatarBehavior(avatar.behavior, baseBehavior) + return { + id: avatar.id, + name: avatar.name, + body: parseAvatarBody(avatar.body, surfacePresets.sphere), + colors: parseColors(avatar.colors), + eyes: parseAvatarEyeDefaults(avatar.eyes), + ...(behavior ? { behavior } : {}), + } + }) + if (!avatars.length) return fallback + const activeAvatarId = avatars.some(avatar => avatar.id === parsed.activeAvatarId) + ? parsed.activeAvatarId! + : avatars[0].id + return { activeAvatarId, avatars } + } catch { + return fallback + } +} diff --git a/src/body.ts b/src/body.ts new file mode 100644 index 0000000..99db4c7 --- /dev/null +++ b/src/body.ts @@ -0,0 +1,117 @@ +import { surfaceLabels, surfacePresets, type SurfaceConfig, type SurfaceType } from './surfaces' + +export type BodyVector = readonly [number, number, number] + +export type BodyNode = { + id: string + name: string + surface: SurfaceConfig + position: BodyVector + rotation: BodyVector +} + +export type AvatarBody = { + primary: SurfaceConfig + nodes: BodyNode[] +} + +export const bodyPrimitiveTypes = [ + 'sphere', + 'cube', + 'capsule', + 'cylinder', + 'cone', + 'diamond', +] as const + +export const MAX_BODY_NODES = 16 + +const allSurfaceTypes = Object.keys(surfacePresets) as SurfaceType[] +const finite = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value) +const vector = (value: unknown): value is BodyVector => + Array.isArray(value) && value.length === 3 && value.every(finite) + +export const parseSurfaceConfig = (value: unknown, fallback: SurfaceConfig): SurfaceConfig => { + if (!value || typeof value !== 'object') return { ...fallback } + const candidate = value as Partial + const type = + candidate.type && allSurfaceTypes.includes(candidate.type) ? candidate.type : fallback.type + const preset = surfacePresets[type] + const numericFields = ['width', 'height', 'depth', 'roundness'] as const + if (numericFields.some(field => !finite(candidate[field]))) return { ...fallback } + if (candidate.morphRoundness !== undefined && !finite(candidate.morphRoundness)) + return { ...fallback } + if (candidate.tipRoundness !== undefined && !finite(candidate.tipRoundness)) + return { ...fallback } + if (candidate.baseRoundness !== undefined && !finite(candidate.baseRoundness)) + return { ...fallback } + return { ...preset, ...candidate, type } +} + +export const parseAvatarBody = (value: unknown, fallbackPrimary: SurfaceConfig): AvatarBody => { + if (!value || typeof value !== 'object') return { primary: fallbackPrimary, nodes: [] } + const candidate = value as Partial + const primary = parseSurfaceConfig(candidate.primary, fallbackPrimary) + const seenIds = new Set() + const nodes = Array.isArray(candidate.nodes) + ? candidate.nodes + .filter((node): node is BodyNode => { + if (!node || typeof node !== 'object') return false + const surface = (node as BodyNode).surface + const id = (node as BodyNode).id + if (id === 'primary' || seenIds.has(id)) return false + const valid = Boolean( + typeof (node as BodyNode).id === 'string' && + id && + typeof (node as BodyNode).name === 'string' && + surface && + bodyPrimitiveTypes.includes(surface.type as (typeof bodyPrimitiveTypes)[number]) && + finite(surface.width) && + finite(surface.height) && + finite(surface.depth) && + finite(surface.roundness) && + vector((node as BodyNode).position) && + vector((node as BodyNode).rotation) + ) + if (valid) seenIds.add(id) + return valid + }) + .slice(0, MAX_BODY_NODES) + .map(node => ({ + ...node, + surface: parseSurfaceConfig(node.surface, surfacePresets[node.surface.type]), + })) + : [] + return { primary, nodes } +} + +export const createBodyNode = ( + type: (typeof bodyPrimitiveTypes)[number], + index: number +): BodyNode => { + const preset = surfacePresets[type] + const scale = 0.34 + const side = index % 2 === 0 ? -1 : 1 + return { + id: `shape-${crypto.randomUUID()}`, + name: `${surfaceLabels[type]} ${index + 1}`, + surface: { + ...preset, + width: preset.width * scale, + height: preset.height * scale, + depth: preset.depth * scale, + }, + position: [side * 82, -72, -18], + rotation: [0, 0, 0], + } +} + +export const duplicateBodyNode = (source: BodyNode): BodyNode => ({ + ...source, + id: `shape-${crypto.randomUUID()}`, + name: `${source.name} copie`, + surface: { ...source.surface }, + position: [source.position[0] + 14, source.position[1] + 14, source.position[2]], + rotation: [...source.rotation], +}) diff --git a/src/canvasPreview.ts b/src/canvasPreview.ts new file mode 100644 index 0000000..ddd213b --- /dev/null +++ b/src/canvasPreview.ts @@ -0,0 +1,12 @@ +import { applyAvatarEyeDefaults, type AvatarEyeDefaults } from './avatars' +import { type Expression } from './geometry' + +export type CanvasPreviewTarget = 'head' | 'eyes' + +export const resolveCanvasPreviewExpression = ( + expression: Expression, + avatarEyes: AvatarEyeDefaults, + bodyEditing: boolean, + target: CanvasPreviewTarget +) => + bodyEditing && target === 'head' ? applyAvatarEyeDefaults(expression, avatarEyes) : expression diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000..2b4967d --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,57 @@ +import { Accordion as AccordionPrimitive } from '@base-ui/react/accordion' +import { ChevronDown } from 'lucide-react' +import type * as React from 'react' + +import { cn } from '../../lib/utils' + +function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) { + return ( + + ) +} + +function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) { + return ( + + ) +} + +function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) { + return ( + + + {children} + + + + ) +} + +function AccordionContent({ className, children, ...props }: AccordionPrimitive.Panel.Props) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionContent, AccordionItem, AccordionTrigger } diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..444f239 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,71 @@ +import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react/alert-dialog' +import type * as React from 'react' + +import { cn } from '../../lib/utils' +import { Button } from './button' + +function AlertDialog(props: AlertDialogPrimitive.Root.Props) { + return +} + +function AlertDialogTrigger(props: AlertDialogPrimitive.Trigger.Props) { + return +} + +function AlertDialogContent({ className, ...props }: AlertDialogPrimitive.Popup.Props) { + return ( + + + + + ) +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return
+} + +function AlertDialogTitle({ className, ...props }: AlertDialogPrimitive.Title.Props) { + return ( + + ) +} + +function AlertDialogDescription({ className, ...props }: AlertDialogPrimitive.Description.Props) { + return ( + + ) +} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<'div'>) { + return
+} + +function AlertDialogCancel(props: AlertDialogPrimitive.Close.Props) { + return } {...props} /> +} + +function AlertDialogAction(props: React.ComponentProps) { + return + + +
+ + +
+
+
+

${copy.api}

+

${copy.apiHelp}

+
+
+
import { createAvatar } from './avatar.js'
+
+const avatar = createAvatar('#avatar', {
+  animation: ${JSON.stringify(firstAnimation)},
+  size: 320,
+})
+
+avatar.play(${JSON.stringify(firstAnimation)})
+avatar.pause()
+avatar.stop()
+

${copy.apiNote}

+
+ +
+ + + + +` +} + +export const generateJavaScriptAvatarPackage = ( + payload: AvatarExportPayload, + language: StudioLanguage = 'en' +) => { + return createStoredZip([ + { name: 'index.html', content: generateJavaScriptAvatarHtml(payload, language) }, + { name: 'avatar.js', content: generateJavaScriptAvatarModule(payload) }, + ]) +} + +const avatarExportSlug = (name: string) => + name + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') || 'avatar' + +const avatarComponentName = (name: string) => { + const identifier = avatarExportSlug(name) + .split('-') + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join('') + return /^[A-Za-z_$]/.test(identifier) ? identifier : `Avatar${identifier}` +} + +export const generateReactAvatarRuntime = () => ` +export type AvatarData = Readonly<{ + version: number + avatar: Readonly<{ name: string } & Record> + expressions: Readonly> + animations: Readonly> +}> + +export type RuntimeAvatar = { + play: (animation?: AnimationName) => RuntimeAvatar + pause: () => RuntimeAvatar + stop: () => RuntimeAvatar + destroy: () => void +} + +export type AvatarRuntimeModule = { + createAvatar: ( + target: HTMLElement, + options: { + animation: AnimationName + autoplay: boolean + loop?: boolean + size: string + onAnimationEnd: (animation: AnimationName) => void + } + ) => RuntimeAvatar +} + +export const AVATAR_RUNTIME_VERSION = 1 +const ENGINE_SOURCE = ${JSON.stringify(standaloneEngineSource)} +const BROWSER_RUNTIME_SOURCE = ${JSON.stringify(proceduralBrowserRuntime)} +const runtimes = new WeakMap>() + +const serializeData = (data: AvatarData) => + JSON.stringify(data) + .replace(/( + data: AvatarData +): Promise> => { + if (data.version !== AVATAR_RUNTIME_VERSION) { + throw new Error( + 'Avatar data version ' + data.version + ' requires a compatible avatar-runtime.ts' + ) + } + const cached = runtimes.get(data) + if (cached) return cached as Promise> + const source = + ENGINE_SOURCE + + '\\nconst DATA = ' + + serializeData(data) + + ';\\n' + + BROWSER_RUNTIME_SOURCE + + '\\nexport function createAvatar(target, options = {}) { return mountAvatar(target, options); }' + const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' })) + const runtime = import(/* @vite-ignore */ url).then(module => { + URL.revokeObjectURL(url) + return module as AvatarRuntimeModule + }) + runtimes.set(data, runtime) + return runtime +} +` + +export const generateReactAvatarData = (payload: AvatarExportPayload) => ` +import type { AvatarData } from './avatar-runtime' + +export const avatarData = ${serializedPayload(payload)} as const satisfies AvatarData +export type AnimationName = keyof typeof avatarData.animations +` + +export const generateReactAvatarComponent = (payload: AvatarExportPayload) => { + const animationNames = Object.keys(payload.animations) + const slug = avatarExportSlug(payload.avatar.name) + const componentName = avatarComponentName(payload.avatar.name) + return ` +import { forwardRef, useEffect, useImperativeHandle, useRef, type CSSProperties } from 'react' +import { loadAvatarRuntime, type RuntimeAvatar } from './avatar-runtime' +import { avatarData, type AnimationName } from './${slug}.avatar' + +export type { AnimationName } from './${slug}.avatar' +export type AvatarHandle = { + play: (animation?: AnimationName) => void + pause: () => void + stop: () => void +} +export type AvatarProps = { + animation?: AnimationName + playing?: boolean + loop?: boolean + size?: number | string + className?: string + style?: CSSProperties + onAnimationEnd?: (animation: AnimationName) => void +} + +export const ${componentName} = forwardRef(function ${componentName}( + { + animation = ${JSON.stringify(animationNames[0])}, + playing = true, + loop, + size = 240, + className, + style, + onAnimationEnd, + }, + ref +) { + const host = useRef(null) + const controller = useRef | null>(null) + const animationRef = useRef(animation) + const playingRef = useRef(playing) + const onAnimationEndRef = useRef(onAnimationEnd) + animationRef.current = animation + playingRef.current = playing + onAnimationEndRef.current = onAnimationEnd + + useEffect(() => { + if (!host.current) return + let disposed = false + let avatar: RuntimeAvatar | null = null + void loadAvatarRuntime(avatarData).then(runtime => { + if (disposed || !host.current) return + avatar = runtime.createAvatar(host.current, { + animation: animationRef.current, + autoplay: playingRef.current, + loop, + size: '100%', + onAnimationEnd: next => onAnimationEndRef.current?.(next), + }) + controller.current = avatar + }) + return () => { + disposed = true + avatar?.destroy() + controller.current = null + } + }, [loop]) + + useEffect(() => { + const avatar = controller.current + if (!avatar) return + if (playing) avatar.play(animation) + else avatar.pause() + }, [animation, playing]) + + useImperativeHandle(ref, () => ({ + play(next = animation) { controller.current?.play(next) }, + pause() { controller.current?.pause() }, + stop() { controller.current?.stop() }, + }), [animation]) + + const dimension = typeof size === 'number' ? size + 'px' : size + return +}) + +export default ${componentName} +` +} + +export const generateReactAvatarPackage = (payload: AvatarExportPayload) => { + const slug = avatarExportSlug(payload.avatar.name) + const componentName = avatarComponentName(payload.avatar.name) + const avatarEntry = `export { default, ${componentName} } from './${componentName}'\nexport type { AnimationName, AvatarHandle, AvatarProps } from './${componentName}'\n` + return createStoredZip([ + { name: 'avatar-runtime.ts', content: generateReactAvatarRuntime() }, + { name: `${slug}.avatar.ts`, content: generateReactAvatarData(payload) }, + { name: `${componentName}.tsx`, content: generateReactAvatarComponent(payload) }, + { name: `${slug}.index.ts`, content: avatarEntry }, + ]) +} + +export const avatarExportFileName = (name: string, extension: 'js' | 'tsx' | 'zip') => { + return `${avatarExportSlug(name)}-avatar.${extension}` +} diff --git a/src/expressionEditing.ts b/src/expressionEditing.ts new file mode 100644 index 0000000..c3dad76 --- /dev/null +++ b/src/expressionEditing.ts @@ -0,0 +1,60 @@ +import type { Expression } from './geometry' + +export type EyeSide = 'Left' | 'Right' +export type EyeLinks = { width: boolean; height: boolean; size: boolean; position?: boolean } + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)) +const opposite = (side: EyeSide): EyeSide => (side === 'Left' ? 'Right' : 'Left') + +export const updateEyeDimension = ( + expression: Expression, + side: EyeSide, + dimension: 'width' | 'height', + value: number, + linked: boolean +) => { + const field = `${dimension}${side}` as 'widthLeft' | 'widthRight' | 'heightLeft' | 'heightRight' + const otherField = `${dimension}${opposite(side)}` as typeof field + const nextValue = clamp(value, 10, 100) + return { + ...expression, + [field]: nextValue, + ...(linked ? { [otherField]: nextValue } : {}), + } +} + +export const scaleEye = ( + expression: Expression, + side: EyeSide, + targetSize: number, + linked: boolean +) => { + const widthField = `width${side}` as 'widthLeft' | 'widthRight' + const heightField = `height${side}` as 'heightLeft' | 'heightRight' + const factor = targetSize / Math.max(expression[widthField], expression[heightField], 1) + const next: Expression = { + ...expression, + [widthField]: clamp(expression[widthField] * factor, 10, 110), + [heightField]: clamp(expression[heightField] * factor, 10, 110), + } + if (!linked) return next + const otherSide = opposite(side) + const otherWidth = `width${otherSide}` as 'widthLeft' | 'widthRight' + const otherHeight = `height${otherSide}` as 'heightLeft' | 'heightRight' + next[otherWidth] = clamp(expression[otherWidth] * factor, 10, 110) + next[otherHeight] = clamp(expression[otherHeight] * factor, 10, 110) + return next +} + +export const updateEyePosition = ( + expression: Expression, + side: EyeSide, + axis: 'X' | 'Y', + value: number, + linked: boolean +) => { + const field = `position${axis}${side}` as + 'positionXLeft' | 'positionXRight' | 'positionYLeft' | 'positionYRight' + const otherField = `position${axis}${opposite(side)}` as typeof field + return { ...expression, [field]: value, ...(linked ? { [otherField]: value } : {}) } +} diff --git a/src/geometry.ts b/src/geometry.ts new file mode 100644 index 0000000..f2cf98c --- /dev/null +++ b/src/geometry.ts @@ -0,0 +1,1251 @@ +import { + cursorLayout, + surfaceFrontSampleAt, + surfacePointAt, + surfaceSampleAt, + type SurfaceConfig, +} from './surfaces' +import type { BodyNode } from './body' + +export type Quaternion = readonly [number, number, number, number] +export type Point3 = readonly [number, number, number] +export type EyeMotion = 'none' | 'microSaccades' | 'shake' +export type BodyMotion = 'none' | 'slowDrift' | 'shake' + +export type Expression = { + id: string + headX: number + headY: number + headZ: number + widthLeft: number + widthRight: number + heightLeft: number + heightRight: number + spacing: number + positionXLeft: number + positionXRight: number + positionYLeft: number + positionYRight: number + leftAngle: number + rightAngle: number + perspective: number + eyeMotion: EyeMotion + bodyMotion: BodyMotion + bodyColor?: string + eyeColor?: string +} + +export type ExpressionNumericField = Exclude< + keyof Expression, + 'id' | 'bodyColor' | 'eyeColor' | 'eyeMotion' | 'bodyMotion' +> + +export type AvatarPose = { + expression: Expression + orientation: Quaternion +} + +export type AvatarGeometry = { + backPaths: string[] + frontPaths: string[] + backNodeIds: (string | null)[] + frontNodeIds: (string | null)[] + headPath: string + leftPath: string + rightPath: string + leftVisible: boolean + rightVisible: boolean + wirePaths: string[] +} + +export type RenderAvatarOptions = { + includeWire?: boolean + bodyNodes?: BodyNode[] +} + +export type EyeEditorGeometry = { + visible: boolean + selectionPath: string + widthGuide: string + heightGuide: string + rotationGuide: string + spacingGuide: string + center: Point3 + widthHandle: Point3 + heightHandle: Point3 + rotateHandle: Point3 + sizeHandle: Point3 + spacingHandle: Point3 +} + +export type BodyNodeEditorGeometry = { + center: Point3 + axes: Record<'x' | 'y' | 'z', Point3> + rings: Record<'x' | 'y' | 'z', Point3[]> +} + +export const RADIUS = 120 +const FOCAL_LENGTH = 620 +const QUARTER_ARC_SAMPLES = 14 + +export const expressionFields: ExpressionNumericField[] = [ + 'headX', + 'headY', + 'headZ', + 'widthLeft', + 'widthRight', + 'heightLeft', + 'heightRight', + 'spacing', + 'positionXLeft', + 'positionXRight', + 'positionYLeft', + 'positionYRight', + 'leftAngle', + 'rightAngle', + 'perspective', +] + +export const clamp = (value: number, min: number, max: number) => + Math.max(min, Math.min(max, value)) + +export const radians = (degrees: number) => (degrees * Math.PI) / 180 + +export const normalizeQuaternion = ([w, x, y, z]: Quaternion): Quaternion => { + const length = Math.hypot(w, x, y, z) || 1 + return [w / length, x / length, y / length, z / length] +} + +export const multiplyQuaternions = ( + [aw, ax, ay, az]: Quaternion, + [bw, bx, by, bz]: Quaternion +): Quaternion => + normalizeQuaternion([ + aw * bw - ax * bx - ay * by - az * bz, + aw * bx + ax * bw + ay * bz - az * by, + aw * by - ax * bz + ay * bw + az * bx, + aw * bz + ax * by - ay * bx + az * bw, + ]) + +export const quaternionFromAxisAngle = ([x, y, z]: Point3, angle: number): Quaternion => { + const halfAngle = angle / 2 + const sine = Math.sin(halfAngle) + return normalizeQuaternion([Math.cos(halfAngle), x * sine, y * sine, z * sine]) +} + +export const quaternionFromEuler = (x: number, y: number, z: number): Quaternion => { + const xRotation = quaternionFromAxisAngle([1, 0, 0], x) + const yRotation = quaternionFromAxisAngle([0, 1, 0], y) + const zRotation = quaternionFromAxisAngle([0, 0, 1], z) + return multiplyQuaternions(multiplyQuaternions(zRotation, xRotation), yRotation) +} + +export const quaternionFromVectors = (from: Point3, to: Point3): Quaternion => { + const dot = from[0] * to[0] + from[1] * to[1] + from[2] * to[2] + const cross: Point3 = [ + from[1] * to[2] - from[2] * to[1], + from[2] * to[0] - from[0] * to[2], + from[0] * to[1] - from[1] * to[0], + ] + return normalizeQuaternion([1 + dot, cross[0], cross[1], cross[2]]) +} + +export const quaternionToEuler = ([w, x, y, z]: Quaternion): Point3 => { + const matrix00 = 1 - 2 * (y * y + z * z) + const matrix01 = 2 * (x * y - z * w) + const matrix10 = 2 * (x * y + z * w) + const matrix11 = 1 - 2 * (x * x + z * z) + const matrix20 = 2 * (x * z - y * w) + const matrix21 = 2 * (y * z + x * w) + const matrix22 = 1 - 2 * (x * x + y * y) + const headX = Math.asin(clamp(matrix21, -1, 1)) + if (Math.abs(Math.cos(headX)) < 0.00001) return [headX, 0, Math.atan2(matrix10, matrix00)] + return [headX, Math.atan2(-matrix20, matrix22), Math.atan2(-matrix01, matrix11)] +} + +const nearestEquivalentAngle = (angle: number, current: number) => { + let result = angle + while (result - current > 180) result -= 360 + while (result - current < -180) result += 360 + return clamp(result, -365, 365) +} + +export const expressionWithOrientation = ( + expression: Expression, + orientation: Quaternion +): Expression => { + const [radiansX, radiansY, radiansZ] = quaternionToEuler(orientation) + const x = (radiansX * 180) / Math.PI + const y = (radiansY * 180) / Math.PI + const z = (radiansZ * 180) / Math.PI + return { + ...expression, + headX: nearestEquivalentAngle(x, expression.headX), + headY: nearestEquivalentAngle(y, expression.headY), + headZ: nearestEquivalentAngle(z, expression.headZ), + } +} + +export const slerpQuaternion = ( + start: Quaternion, + end: Quaternion, + progress: number +): Quaternion => { + let target = end + let dot = start.reduce((total, value, index) => total + value * target[index], 0) + if (dot < 0) { + target = target.map(value => -value) as unknown as Quaternion + dot = -dot + } + if (dot > 0.9995) { + return normalizeQuaternion( + start.map( + (value, index) => value + (target[index] - value) * progress + ) as unknown as Quaternion + ) + } + const angle = Math.acos(clamp(dot, -1, 1)) + const sine = Math.sin(angle) + const startWeight = Math.sin((1 - progress) * angle) / sine + const targetWeight = Math.sin(progress * angle) / sine + return normalizeQuaternion( + start.map( + (value, index) => value * startWeight + target[index] * targetWeight + ) as unknown as Quaternion + ) +} + +export const rotateWithQuaternion = ([w, x, y, z]: Quaternion, [px, py, pz]: Point3): Point3 => { + const tx = 2 * (y * pz - z * py) + const ty = 2 * (z * px - x * pz) + const tz = 2 * (x * py - y * px) + return [ + px + w * tx + (y * tz - z * ty), + py + w * ty + (z * tx - x * tz), + pz + w * tz + (x * ty - y * tx), + ] +} + +const roundedRectangle = (width: number, height: number): (readonly [number, number])[] => { + const halfWidth = width / 2 + const halfHeight = height / 2 + const cornerRadius = Math.min(halfHeight, halfWidth) + const points: (readonly [number, number])[] = [] + const addLine = (start: readonly [number, number], end: readonly [number, number]) => { + const samples = Math.max(2, Math.ceil(Math.hypot(end[0] - start[0], end[1] - start[1]) / 1.5)) + for (let index = 0; index < samples; index += 1) { + const progress = index / samples + points.push([ + start[0] + (end[0] - start[0]) * progress, + start[1] + (end[1] - start[1]) * progress, + ]) + } + } + const addArc = (centerX: number, centerY: number, startAngle: number) => { + for (let index = 0; index < QUARTER_ARC_SAMPLES; index += 1) { + const angle = startAngle + (index / QUARTER_ARC_SAMPLES) * (Math.PI / 2) + points.push([ + centerX + Math.cos(angle) * cornerRadius, + centerY + Math.sin(angle) * cornerRadius, + ]) + } + } + addLine([-halfWidth + cornerRadius, -halfHeight], [halfWidth - cornerRadius, -halfHeight]) + addArc(halfWidth - cornerRadius, -halfHeight + cornerRadius, -Math.PI / 2) + addLine([halfWidth, -halfHeight + cornerRadius], [halfWidth, halfHeight - cornerRadius]) + addArc(halfWidth - cornerRadius, halfHeight - cornerRadius, 0) + addLine([halfWidth - cornerRadius, halfHeight], [-halfWidth + cornerRadius, halfHeight]) + addArc(-halfWidth + cornerRadius, halfHeight - cornerRadius, Math.PI / 2) + addLine([-halfWidth, halfHeight - cornerRadius], [-halfWidth, -halfHeight + cornerRadius]) + addArc(-halfWidth + cornerRadius, -halfHeight + cornerRadius, Math.PI) + return points +} + +const project = (point: Point3, perspective: number): Point3 => { + const denominator = FOCAL_LENGTH - point[2] * perspective + const scale = Math.abs(denominator) < 0.0001 ? FOCAL_LENGTH / 0.0001 : FOCAL_LENGTH / denominator + return [point[0] * scale, point[1] * scale, point[2]] +} + +export const axisVector = (axis: 'x' | 'y' | 'z'): Point3 => + axis === 'x' ? [1, 0, 0] : axis === 'y' ? [0, 1, 0] : [0, 0, 1] + +export const rotateExpressionAroundAxis = ( + expression: Expression, + axis: 'x' | 'y' | 'z', + deltaDegrees: number +) => { + const startOrientation = poseFromExpression(expression).orientation + const worldAxis = rotateWithQuaternion(startOrientation, axisVector(axis)) + const orientation = multiplyQuaternions( + quaternionFromAxisAngle(worldAxis, radians(deltaDegrees)), + startOrientation + ) + return expressionWithOrientation(expression, orientation) +} + +export const rotateExpressionAroundCamera = (expression: Expression, deltaRadians: number) => { + const startOrientation = poseFromExpression(expression).orientation + return expressionWithOrientation( + expression, + multiplyQuaternions(quaternionFromAxisAngle([0, 0, 1], deltaRadians), startOrientation) + ) +} + +const arcballVector = ([xValue, yValue]: readonly [number, number]): Point3 => { + const x = xValue / RADIUS + const y = yValue / RADIUS + const squaredLength = x * x + y * y + if (squaredLength <= 1) return [x, y, Math.sqrt(1 - squaredLength)] + const length = Math.sqrt(squaredLength) + return [x / length, y / length, 0] +} + +export const rotateExpressionWithArcball = ( + expression: Expression, + startPoint: readonly [number, number], + currentPoint: readonly [number, number] +) => { + const startOrientation = poseFromExpression(expression).orientation + const delta = quaternionFromVectors(arcballVector(startPoint), arcballVector(currentPoint)) + return expressionWithOrientation(expression, multiplyQuaternions(delta, startOrientation)) +} + +export const rotationRing = (pose: AvatarPose, axis: 'x' | 'y' | 'z', radius = 30): Point3[] => + Array.from({ length: 97 }, (_, index) => { + const angle = (index / 96) * Math.PI * 2 + const cosine = Math.cos(angle) + const sine = Math.sin(angle) + const point: Point3 = + axis === 'x' ? [0, cosine, sine] : axis === 'y' ? [cosine, 0, sine] : [cosine, sine, 0] + const rotated = rotateWithQuaternion(pose.orientation, point) + return [rotated[0] * radius, rotated[1] * radius, rotated[2]] + }) + +export const renderBodyNodeEditor = ( + pose: AvatarPose, + node: BodyNode, + axisLength = 34, + ringRadius = 26 +): BodyNodeEditorGeometry => { + const projectInHeadSpace = (point: Point3) => + project(rotateWithQuaternion(pose.orientation, point), pose.expression.perspective) + const center = projectInHeadSpace(node.position) + const localOrientation = quaternionFromEuler( + radians(node.rotation[0]), + radians(node.rotation[1]), + radians(node.rotation[2]) + ) + const axes = Object.fromEntries( + (['x', 'y', 'z'] as const).map(axis => { + const vector = rotateWithQuaternion(localOrientation, axisVector(axis)) + return [ + axis, + projectInHeadSpace([ + node.position[0] + vector[0] * axisLength, + node.position[1] + vector[1] * axisLength, + node.position[2] + vector[2] * axisLength, + ]), + ] + }) + ) as BodyNodeEditorGeometry['axes'] + ;(['x', 'y', 'z'] as const).forEach(axis => { + const endpoint = axes[axis] + if (Math.hypot(endpoint[0] - center[0], endpoint[1] - center[1]) >= 12) return + const fallback: Point3 = + axis === 'x' + ? [center[0] + 18, center[1], endpoint[2]] + : axis === 'y' + ? [center[0], center[1] + 18, endpoint[2]] + : [center[0] + 14, center[1] + 14, endpoint[2]] + axes[axis] = fallback + }) + const rings = Object.fromEntries( + (['x', 'y', 'z'] as const).map(axis => [ + axis, + Array.from({ length: 65 }, (_, index) => { + const angle = (index / 64) * Math.PI * 2 + const cosine = Math.cos(angle) * ringRadius + const sine = Math.sin(angle) * ringRadius + const localPoint: Point3 = + axis === 'x' ? [0, cosine, sine] : axis === 'y' ? [cosine, 0, sine] : [cosine, sine, 0] + const rotated = rotateWithQuaternion(localOrientation, localPoint) + return projectInHeadSpace([ + node.position[0] + rotated[0], + node.position[1] + rotated[1], + node.position[2] + rotated[2], + ]) + }), + ]) + ) as BodyNodeEditorGeometry['rings'] + return { center, axes, rings } +} + +export const translateBodyNodeAlongLocalAxis = ( + node: BodyNode, + axis: 'x' | 'y' | 'z', + distance: number +): BodyNode => { + const orientation = quaternionFromEuler( + radians(node.rotation[0]), + radians(node.rotation[1]), + radians(node.rotation[2]) + ) + const direction = rotateWithQuaternion(orientation, axisVector(axis)) + return { + ...node, + position: [ + node.position[0] + direction[0] * distance, + node.position[1] + direction[1] * distance, + node.position[2] + direction[2] * distance, + ], + } +} + +export const translateBodyNodeInCameraPlane = ( + node: BodyNode, + pose: AvatarPose, + screenDeltaX: number, + screenDeltaY: number +): BodyNode => { + const cameraPosition = rotateWithQuaternion(pose.orientation, node.position) + const denominator = FOCAL_LENGTH - cameraPosition[2] * pose.expression.perspective + const perspectiveScale = + Math.abs(denominator) < 0.0001 ? FOCAL_LENGTH / 0.0001 : FOCAL_LENGTH / denominator + const [w, x, y, z] = pose.orientation + const headDelta = rotateWithQuaternion( + [w, -x, -y, -z], + [screenDeltaX / perspectiveScale, screenDeltaY / perspectiveScale, 0] + ) + return { + ...node, + position: [ + node.position[0] + headDelta[0], + node.position[1] + headDelta[1], + node.position[2] + headDelta[2], + ], + } +} + +export const rotateBodyNodeAroundLocalAxis = ( + node: BodyNode, + axis: 'x' | 'y' | 'z', + deltaDegrees: number +): BodyNode => { + const orientation = quaternionFromEuler( + radians(node.rotation[0]), + radians(node.rotation[1]), + radians(node.rotation[2]) + ) + const rotated = multiplyQuaternions( + orientation, + quaternionFromAxisAngle(axisVector(axis), radians(deltaDegrees)) + ) + const next = quaternionToEuler(rotated).map(value => (value * 180) / Math.PI) as [ + number, + number, + number, + ] + return { + ...node, + rotation: next.map((value, index) => nearestEquivalentAngle(value, node.rotation[index])) as [ + number, + number, + number, + ], + } +} + +const path = (points: Point3[], close = true) => { + if (!points.length) return '' + return `M${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}${points + .slice(1) + .map(point => `L${point[0].toFixed(2)} ${point[1].toFixed(2)}`) + .join('')}${close ? 'Z' : ''}` +} + +export const poseFromExpression = (expression: Expression): AvatarPose => ({ + expression, + orientation: quaternionFromEuler( + radians(expression.headX), + radians(expression.headY), + radians(expression.headZ) + ), +}) + +export const interpolatePose = (from: AvatarPose, to: AvatarPose, progress: number): AvatarPose => { + const expression: Expression = { ...from.expression } + expressionFields.forEach(field => { + expression[field] = + from.expression[field] + (to.expression[field] - from.expression[field]) * progress + }) + return { + expression, + orientation: slerpQuaternion(from.orientation, to.orientation, progress), + } +} + +type ProjectedSurfacePoint = { point: Point3; normal: Point3 } +type LocalSurfacePoint = ProjectedSurfacePoint + +const MAX_SURFACE_CACHE_ENTRIES = 24 +const HEAD_LATITUDE_SAMPLES = 25 +const HEAD_LONGITUDE_SAMPLES = 73 +const PRIMITIVE_RING_SAMPLES = 144 +const ROUNDED_PRIMITIVE_LATITUDE_SAMPLES = 33 +const ROUNDED_PRIMITIVE_LONGITUDE_SAMPLES = 73 +const headSamplesCache = new Map() +const accessorySamplesCache = new Map() +const wireSamplesCache = new Map() + +const surfaceCacheKey = (surface: SurfaceConfig) => + [ + surface.type, + surface.width, + surface.height, + surface.depth, + surface.roundness, + surface.morphRoundness, + surface.tipRoundness, + surface.baseRoundness, + ] + .map(value => (typeof value === 'number' ? value.toFixed(4) : value)) + .join(':') + +const cacheSurfaceValue = (cache: Map, key: string, value: Value) => { + if (cache.size >= MAX_SURFACE_CACHE_ENTRIES) cache.delete(cache.keys().next().value!) + cache.set(key, value) + return value +} + +const localSurfacePoint = ( + surface: SurfaceConfig, + longitude: number, + latitude: number +): LocalSurfacePoint => surfaceSampleAt(surface, longitude, latitude) + +const projectLocalSurfacePoint = ( + pose: AvatarPose, + sample: LocalSurfacePoint +): ProjectedSurfacePoint => ({ + point: project(rotateWithQuaternion(pose.orientation, sample.point), pose.expression.perspective), + normal: rotateWithQuaternion(pose.orientation, sample.normal), +}) + +const canonicalFaceCoordinates = (x: number, y: number): readonly [number, number] => { + const longitude = x / RADIUS + const latitude = y / RADIUS + return [RADIUS * Math.cos(latitude) * Math.sin(longitude), RADIUS * Math.sin(latitude)] +} + +const projectFacePoint = ( + pose: AvatarPose, + surface: SurfaceConfig, + x: number, + y: number +): ProjectedSurfacePoint => { + const [faceX, faceY] = canonicalFaceCoordinates(x, y) + return projectLocalSurfacePoint(pose, surfaceFrontSampleAt(surface, faceX, faceY)) +} + +const eyePoints = ( + pose: AvatarPose, + surface: SurfaceConfig, + side: -1 | 1, + blink: number +): ProjectedSurfacePoint[] => { + const expression = pose.expression + const suffix = side < 0 ? 'Left' : 'Right' + const width = expression[`width${suffix}`] + const restingHeight = expression[`height${suffix}`] + const height = 5 + (restingHeight - 5) * blink + const centerX = (side * expression.spacing) / 2 + expression[`positionX${suffix}`] + const centerY = expression[`positionY${suffix}`] + const angle = radians(side < 0 ? expression.leftAngle : expression.rightAngle) + return roundedRectangle(width, height).map(([localX, localY]) => { + const rotatedX = localX * Math.cos(angle) - localY * Math.sin(angle) + const rotatedY = localX * Math.sin(angle) + localY * Math.cos(angle) + return projectFacePoint(pose, surface, centerX + rotatedX, centerY + rotatedY) + }) +} + +const visiblePath = (points: ProjectedSurfacePoint[]) => { + const segments: Point3[][] = [] + let segment: Point3[] = [] + points.forEach(({ point, normal }) => { + if (normal[2] > 0) segment.push(point) + else if (segment.length) { + segments.push(segment) + segment = [] + } + }) + if (segment.length) segments.push(segment) + return segments + .filter(item => item.length > 1) + .map(item => path(item, false)) + .join('') +} + +const wirePaths = (pose: AvatarPose, surface: SurfaceConfig): string[] => { + const key = surfaceCacheKey(surface) + let samples = wireSamplesCache.get(key) + if (!samples) { + const parallels = [-60, -30, 0, 30, 60].map(latitude => + Array.from({ length: 73 }, (_, index) => + localSurfacePoint(surface, radians(-180 + index * 5), radians(latitude)) + ) + ) + const meridians = Array.from( + { length: 12 }, + (_, longitudeIndex) => -150 + longitudeIndex * 30 + ).map(longitude => + Array.from({ length: 37 }, (_, index) => + localSurfacePoint(surface, radians(longitude), radians(-90 + index * 5)) + ) + ) + samples = cacheSurfaceValue(wireSamplesCache, key, [...parallels, ...meridians]) + } + return samples.map(curve => + visiblePath(curve.map(sample => projectLocalSurfacePoint(pose, sample))) + ) +} + +const projectEyePoint = ( + pose: AvatarPose, + surface: SurfaceConfig, + side: -1 | 1, + localX: number, + localY: number +): Point3 => { + const expression = pose.expression + const suffix = side < 0 ? 'Left' : 'Right' + const angle = radians(side < 0 ? expression.leftAngle : expression.rightAngle) + const rotatedX = localX * Math.cos(angle) - localY * Math.sin(angle) + const rotatedY = localX * Math.sin(angle) + localY * Math.cos(angle) + return projectFacePoint( + pose, + surface, + (side * expression.spacing) / 2 + expression[`positionX${suffix}`] + rotatedX, + expression[`positionY${suffix}`] + rotatedY + ).point +} + +export const renderEyeEditor = ( + pose: AvatarPose, + surface: SurfaceConfig, + side: -1 | 1 +): EyeEditorGeometry => { + const expression = pose.expression + const suffix = side < 0 ? 'Left' : 'Right' + const width = expression[`width${suffix}`] + const height = expression[`height${suffix}`] + const selectedSamples = eyePoints(pose, surface, side, 1) + const selectedPoints = selectedSamples.map(sample => sample.point) + const center = projectEyePoint(pose, surface, side, 0, 0) + const widthHandle = projectEyePoint(pose, surface, side, width / 2 + 9, 0) + const heightHandle = projectEyePoint(pose, surface, side, 0, -height / 2 - 9) + const rotateHandle = projectEyePoint(pose, surface, side, 0, -height / 2 - 30) + const sizeHandle = projectEyePoint(pose, surface, side, width / 2 + 11, height / 2 + 11) + const leftCenter = projectEyePoint(pose, surface, -1, 0, 0) + const rightCenter = projectEyePoint(pose, surface, 1, 0, 0) + const spacingCenterX = (expression.positionXLeft + expression.positionXRight) / 2 + const spacingCenterY = (expression.positionYLeft + expression.positionYRight) / 2 + const spacingHandle = projectFacePoint( + pose, + surface, + spacingCenterX, + spacingCenterY + height / 2 + 34 + ).point + const spacingMiddle: Point3 = [ + (leftCenter[0] + rightCenter[0]) / 2, + (leftCenter[1] + rightCenter[1]) / 2, + (leftCenter[2] + rightCenter[2]) / 2, + ] + const line = (from: Point3, to: Point3) => path([from, to], false) + return { + visible: selectedSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0, + selectionPath: path(selectedPoints), + widthGuide: line(center, widthHandle), + heightGuide: line(center, heightHandle), + rotationGuide: line(heightHandle, rotateHandle), + spacingGuide: `${line(leftCenter, rightCenter)}${line(spacingMiddle, spacingHandle)}`, + center, + widthHandle, + heightHandle, + rotateHandle, + sizeHandle, + spacingHandle, + } +} + +const convexHull = (points: Point3[]): Point3[] => { + const sorted = [...points].sort((left, right) => left[0] - right[0] || left[1] - right[1]) + const cross = (origin: Point3, first: Point3, second: Point3) => + (first[0] - origin[0]) * (second[1] - origin[1]) - + (first[1] - origin[1]) * (second[0] - origin[0]) + const half = (source: Point3[]) => { + const result: Point3[] = [] + source.forEach(point => { + while (result.length >= 2 && cross(result.at(-2)!, result.at(-1)!, point) <= 0) result.pop() + result.push(point) + }) + return result + } + return [...half(sorted).slice(0, -1), ...half(sorted.reverse()).slice(0, -1)] +} + +const smoothClosedPath = (points: Point3[]) => { + if (points.length < 3) return path(points) + const pointAt = (index: number) => points[(index + points.length) % points.length] + return `M${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}${points + .map((point, index) => { + const previous = pointAt(index - 1) + const next = pointAt(index + 1) + const afterNext = pointAt(index + 2) + const firstControl: Point3 = [ + point[0] + (next[0] - previous[0]) / 6, + point[1] + (next[1] - previous[1]) / 6, + point[2], + ] + const secondControl: Point3 = [ + next[0] - (afterNext[0] - point[0]) / 6, + next[1] - (afterNext[1] - point[1]) / 6, + next[2], + ] + return `C${firstControl[0].toFixed(2)} ${firstControl[1].toFixed(2)} ${secondControl[0].toFixed(2)} ${secondControl[1].toFixed(2)} ${next[0].toFixed(2)} ${next[1].toFixed(2)}` + }) + .join('')}Z` +} + +const densifyClosedPoints = (points: Point3[], maximumDistance = 7) => + points.flatMap((point, index) => { + const next = points[(index + 1) % points.length] + const steps = Math.max( + 1, + Math.ceil(Math.hypot(next[0] - point[0], next[1] - point[1]) / maximumDistance) + ) + return Array.from({ length: steps }, (_, step) => { + const progress = step / steps + return [ + point[0] + (next[0] - point[0]) * progress, + point[1] + (next[1] - point[1]) * progress, + point[2] + (next[2] - point[2]) * progress, + ] as Point3 + }) + }) + +const smoothOpenPath = (points: Point3[]) => { + if (!points.length) return '' + if (points.length === 1) return `${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}` + return points + .slice(0, -1) + .map((point, index) => { + const previous = points[Math.max(0, index - 1)] + const next = points[index + 1] + const afterNext = points[Math.min(points.length - 1, index + 2)] + const firstControlX = point[0] + (next[0] - previous[0]) / 6 + const firstControlY = point[1] + (next[1] - previous[1]) / 6 + const secondControlX = next[0] - (afterNext[0] - point[0]) / 6 + const secondControlY = next[1] - (afterNext[1] - point[1]) / 6 + return `C${firstControlX.toFixed(2)} ${firstControlY.toFixed(2)} ${secondControlX.toFixed(2)} ${secondControlY.toFixed(2)} ${next[0].toFixed(2)} ${next[1].toFixed(2)}` + }) + .join('') +} + +const projectLocalPoint = (pose: AvatarPose, point: Point3) => + project(rotateWithQuaternion(pose.orientation, point), pose.expression.perspective) + +const ringPoints = (width: number, depth: number, y: number) => + Array.from({ length: PRIMITIVE_RING_SAMPLES + 1 }, (_, index) => { + const angle = (index / PRIMITIVE_RING_SAMPLES) * Math.PI * 2 + return [(width / 2) * Math.sin(angle), y, (depth / 2) * Math.cos(angle)] as Point3 + }) + +const projectedRoundedPrimitivePath = (pose: AvatarPose, surface: SurfaceConfig) => { + const key = surfaceCacheKey(surface) + let localSamples = headSamplesCache.get(key) + if (!localSamples) { + localSamples = Array.from( + { length: ROUNDED_PRIMITIVE_LATITUDE_SAMPLES }, + (_, latitudeIndex) => { + const latitude = + -Math.PI / 2 + (latitudeIndex / (ROUNDED_PRIMITIVE_LATITUDE_SAMPLES - 1)) * Math.PI + return Array.from({ length: ROUNDED_PRIMITIVE_LONGITUDE_SAMPLES }, (_, longitudeIndex) => { + const longitude = + -Math.PI + (longitudeIndex / (ROUNDED_PRIMITIVE_LONGITUDE_SAMPLES - 1)) * Math.PI * 2 + return surfacePointAt(surface, longitude, latitude) + }) + } + ).flat() + cacheSurfaceValue(headSamplesCache, key, localSamples) + } + const projected = localSamples.map(point => projectLocalPoint(pose, point)) + return smoothClosedPath(densifyClosedPoints(convexHull(projected))) +} + +const projectedCylinderPath = (pose: AvatarPose, surface: SurfaceConfig) => { + if (surface.roundness > 0 || (surface.morphRoundness ?? 0) > 0) { + return projectedRoundedPrimitivePath(pose, surface) + } + + const halfHeight = surface.height / 2 + const projected = [ + ...ringPoints(surface.width, surface.depth, -halfHeight), + ...ringPoints(surface.width, surface.depth, halfHeight), + ].map(point => projectLocalPoint(pose, point)) + return smoothClosedPath(densifyClosedPoints(convexHull(projected))) +} + +const projectedCursorBodyPath = (pose: AvatarPose, surface: SurfaceConfig) => { + const layout = cursorLayout(surface) + const halfHeight = layout.bodyHeight / 2 + const projected = [ + ...ringPoints(layout.bodyWidth, layout.bodyDepth, layout.bodyCenterY - halfHeight), + ...ringPoints(layout.bodyWidth, layout.bodyDepth, layout.bodyCenterY + halfHeight), + ].map(point => projectLocalPoint(pose, point)) + return smoothClosedPath(densifyClosedPoints(convexHull(projected))) +} + +const projectedCursorConePath = (pose: AvatarPose, surface: SurfaceConfig) => { + const layout = cursorLayout(surface) + const apex = projectLocalPoint(pose, [0, layout.coneApexY, 0]) + const base = ringPoints(surface.width, surface.depth, layout.coneBaseY).map(point => + projectLocalPoint(pose, point) + ) + return smoothClosedPath(densifyClosedPoints(convexHull([...base, apex]))) +} + +const projectedConePath = (pose: AvatarPose, surface: SurfaceConfig) => { + if ( + (surface.morphRoundness ?? 0) > 0 || + (surface.tipRoundness ?? 0) > 0 || + (surface.baseRoundness ?? 0) > 0 + ) { + return projectedRoundedPrimitivePath(pose, surface) + } + + const apex = projectLocalPoint(pose, [0, -surface.height / 2, 0]) + const base = ringPoints(surface.width, surface.depth, surface.height / 2).map(point => + projectLocalPoint(pose, point) + ) + const hull = convexHull([...base, apex]) + const apexIndex = hull.findIndex( + point => Math.hypot(point[0] - apex[0], point[1] - apex[1]) < 0.01 + ) + if (apexIndex < 0) return smoothClosedPath(hull) + + const ordered = [...hull.slice(apexIndex), ...hull.slice(0, apexIndex)] + const baseArc = ordered.slice(1) + if (baseArc.length < 2) return path(hull) + return `M${apex[0].toFixed(2)} ${apex[1].toFixed(2)}L${baseArc[0][0].toFixed(2)} ${baseArc[0][1].toFixed(2)}${smoothOpenPath(baseArc)}L${apex[0].toFixed(2)} ${apex[1].toFixed(2)}Z` +} + +const projectedCubePath = (pose: AvatarPose, surface: SurfaceConfig) => { + if (surface.roundness > 0) return projectedRoundedPrimitivePath(pose, surface) + + const halfWidth = surface.width / 2 + const halfHeight = surface.height / 2 + const halfDepth = surface.depth / 2 + const vertices = [-1, 1].flatMap(x => + [-1, 1].flatMap(y => [-1, 1].map(z => [x * halfWidth, y * halfHeight, z * halfDepth] as Point3)) + ) + return path(convexHull(vertices.map(point => projectLocalPoint(pose, point)))) +} + +const projectedDiamondPath = (pose: AvatarPose, surface: SurfaceConfig) => { + if (surface.roundness > 0) return projectedRoundedPrimitivePath(pose, surface) + + const halfWidth = surface.width / 2 + const halfHeight = surface.height / 2 + const halfDepth = surface.depth / 2 + const vertices: Point3[] = [ + [-halfWidth, 0, 0], + [halfWidth, 0, 0], + [0, -halfHeight, 0], + [0, halfHeight, 0], + [0, 0, -halfDepth], + [0, 0, halfDepth], + ] + return path(convexHull(vertices.map(point => projectLocalPoint(pose, point)))) +} + +type ProjectedEllipse = { + centerX: number + centerY: number + majorRadius: number + minorRadius: number + rotation: number +} + +const ellipseProjection = ( + centerX: number, + centerY: number, + covarianceXX: number, + covarianceXY: number, + covarianceYY: number +): ProjectedEllipse | null => { + const trace = covarianceXX + covarianceYY + const difference = Math.hypot(covarianceXX - covarianceYY, covarianceXY * 2) + const majorSquared = (trace + difference) / 2 + const minorSquared = (trace - difference) / 2 + if (majorSquared <= 0 || minorSquared <= 0) return null + + return { + centerX, + centerY, + majorRadius: Math.sqrt(majorSquared), + minorRadius: Math.sqrt(minorSquared), + rotation: Math.atan2(covarianceXY * 2, covarianceXX - covarianceYY) / 2, + } +} + +const ellipsePath = ({ + centerX, + centerY, + majorRadius, + minorRadius, + rotation, +}: ProjectedEllipse) => { + const rotationDegrees = (rotation * 180) / Math.PI + const offsetX = Math.cos(rotation) * majorRadius + const offsetY = Math.sin(rotation) * majorRadius + const startX = centerX + offsetX + const startY = centerY + offsetY + const endX = centerX - offsetX + const endY = centerY - offsetY + + return `M${startX.toFixed(2)} ${startY.toFixed(2)}A${majorRadius.toFixed(2)} ${minorRadius.toFixed(2)} ${rotationDegrees.toFixed(2)} 0 1 ${endX.toFixed(2)} ${endY.toFixed(2)}A${majorRadius.toFixed(2)} ${minorRadius.toFixed(2)} ${rotationDegrees.toFixed(2)} 0 1 ${startX.toFixed(2)} ${startY.toFixed(2)}Z` +} + +const projectedEllipsoid = ( + pose: AvatarPose, + axes: Point3, + localCenter: Point3 = [0, 0, 0] +): ProjectedEllipse | null => { + const rotatedAxes = [ + rotateWithQuaternion(pose.orientation, [1, 0, 0]), + rotateWithQuaternion(pose.orientation, [0, 1, 0]), + rotateWithQuaternion(pose.orientation, [0, 0, 1]), + ] + const center = rotateWithQuaternion(pose.orientation, localCenter) + + if (Math.abs(pose.expression.perspective) < 0.0001) { + const covarianceXX = rotatedAxes.reduce( + (total, axis, index) => total + axis[0] * axis[0] * axes[index] * axes[index], + 0 + ) + const covarianceXY = rotatedAxes.reduce( + (total, axis, index) => total + axis[0] * axis[1] * axes[index] * axes[index], + 0 + ) + const covarianceYY = rotatedAxes.reduce( + (total, axis, index) => total + axis[1] * axis[1] * axes[index] * axes[index], + 0 + ) + return ellipseProjection(center[0], center[1], covarianceXX, covarianceXY, covarianceYY) + } + + const inverseAxesSquared = axes.map(axis => 1 / (axis * axis)) + const quadratic = Array.from({ length: 3 }, (_, row) => + Array.from({ length: 3 }, (_, column) => + rotatedAxes.reduce( + (total, axis, index) => total + axis[row] * inverseAxesSquared[index] * axis[column], + 0 + ) + ) + ) + const focalLength = FOCAL_LENGTH / pose.expression.perspective + const cameraOffset: Point3 = [-center[0], -center[1], focalLength - center[2]] + const cameraNormal: Point3 = [ + quadratic[0][0] * cameraOffset[0] + + quadratic[0][1] * cameraOffset[1] + + quadratic[0][2] * cameraOffset[2], + quadratic[1][0] * cameraOffset[0] + + quadratic[1][1] * cameraOffset[1] + + quadratic[1][2] * cameraOffset[2], + quadratic[2][0] * cameraOffset[0] + + quadratic[2][1] * cameraOffset[1] + + quadratic[2][2] * cameraOffset[2], + ] + const cameraTerm = + cameraOffset[0] * cameraNormal[0] + + cameraOffset[1] * cameraNormal[1] + + cameraOffset[2] * cameraNormal[2] - + 1 + const tangentLinear = [cameraNormal[0], cameraNormal[1], -focalLength * cameraNormal[2]] + const rayQuadratic = [ + [quadratic[0][0], quadratic[0][1], -focalLength * quadratic[0][2]], + [quadratic[1][0], quadratic[1][1], -focalLength * quadratic[1][2]], + [ + -focalLength * quadratic[2][0], + -focalLength * quadratic[2][1], + focalLength * focalLength * quadratic[2][2], + ], + ] + const conic = Array.from({ length: 3 }, (_, row) => + Array.from( + { length: 3 }, + (_, column) => + tangentLinear[row] * tangentLinear[column] - cameraTerm * rayQuadratic[row][column] + ) + ) + const determinant = conic[0][0] * conic[1][1] - conic[0][1] * conic[0][1] + if (Math.abs(determinant) < 1e-12) return null + + const centerX = -(conic[1][1] * conic[0][2] - conic[0][1] * conic[1][2]) / determinant + const centerY = (conic[0][1] * conic[0][2] - conic[0][0] * conic[1][2]) / determinant + const centeredConstant = conic[2][2] + conic[0][2] * centerX + conic[1][2] * centerY + const scale = -centeredConstant + if (Math.abs(scale) < 1e-12) return null + + const shapeXX = conic[0][0] / scale + const shapeXY = conic[0][1] / scale + const shapeYY = conic[1][1] / scale + const shapeDeterminant = shapeXX * shapeYY - shapeXY * shapeXY + if (shapeDeterminant <= 0) return null + + return ellipseProjection( + centerX, + centerY, + shapeYY / shapeDeterminant, + -shapeXY / shapeDeterminant, + shapeXX / shapeDeterminant + ) +} + +const projectedEllipsoidPath = (pose: AvatarPose, surface: SurfaceConfig) => { + const ellipse = projectedEllipsoid(pose, [ + surface.width / 2, + surface.height / 2, + surface.depth / 2, + ]) + const isSphere = surface.width === surface.height && surface.height === surface.depth + if (ellipse && isSphere) { + const radius = (ellipse.majorRadius + ellipse.minorRadius) / 2 + return ellipsePath({ + centerX: 0, + centerY: 0, + majorRadius: radius, + minorRadius: radius, + rotation: 0, + }) + } + return ellipse ? ellipsePath(ellipse) : null +} + +const mickeyEarPaths = (pose: AvatarPose, surface: SurfaceConfig) => { + if (surface.type !== 'mickey') return [] + + const radius = Math.min(surface.width, surface.height) * 0.23 + const depthRadius = Math.min(radius, surface.depth * 0.29) + const centerX = surface.width * 0.37 + const centerY = -surface.height * 0.39 + const centerZ = -surface.depth * 0.12 + const axes: Point3 = [radius, radius, depthRadius] + return [-1, 1] + .map(side => projectedEllipsoid(pose, axes, [side * centerX, centerY, centerZ])) + .filter((ear): ear is ProjectedEllipse => ear !== null) + .map(ellipsePath) +} + +const compositeBackPaths = (pose: AvatarPose, surface: SurfaceConfig) => { + if (surface.type === 'mickey') return mickeyEarPaths(pose, surface) + if (surface.type === 'cursor') return [projectedCursorConePath(pose, surface)] + return [] +} + +const ellipsePoints = (ellipse: ProjectedEllipse) => + Array.from({ length: PRIMITIVE_RING_SAMPLES }, (_, index) => { + const angle = (index / PRIMITIVE_RING_SAMPLES) * Math.PI * 2 + const major = Math.cos(angle) * ellipse.majorRadius + const minor = Math.sin(angle) * ellipse.minorRadius + return [ + ellipse.centerX + major * Math.cos(ellipse.rotation) - minor * Math.sin(ellipse.rotation), + ellipse.centerY + major * Math.sin(ellipse.rotation) + minor * Math.cos(ellipse.rotation), + 0, + ] as Point3 + }) + +const smoothHullPath = (points: Point3[]) => { + if (points.length < 3) return path(points) + const distances = points.map((point, index) => { + const next = points[(index + 1) % points.length] + return Math.hypot(next[0] - point[0], next[1] - point[1]) + }) + const sortedDistances = [...distances].sort((left, right) => left - right) + const medianDistance = sortedDistances[Math.floor(sortedDistances.length / 2)] || 1 + const straightThreshold = Math.max(8, medianDistance * 3.5) + const straightEdges = distances.map(distance => distance > straightThreshold) + + return `M${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}${points + .map((point, index) => { + const nextIndex = (index + 1) % points.length + const next = points[nextIndex] + if (straightEdges[index]) return `L${next[0].toFixed(2)} ${next[1].toFixed(2)}` + const previous = straightEdges[(index - 1 + points.length) % points.length] + ? point + : points[(index - 1 + points.length) % points.length] + const afterNext = straightEdges[nextIndex] ? next : points[(index + 2) % points.length] + const firstControlX = point[0] + (next[0] - previous[0]) / 6 + const firstControlY = point[1] + (next[1] - previous[1]) / 6 + const secondControlX = next[0] - (afterNext[0] - point[0]) / 6 + const secondControlY = next[1] - (afterNext[1] - point[1]) / 6 + return `C${firstControlX.toFixed(2)} ${firstControlY.toFixed(2)} ${secondControlX.toFixed(2)} ${secondControlY.toFixed(2)} ${next[0].toFixed(2)} ${next[1].toFixed(2)}` + }) + .join('')}Z` +} + +const projectedCapsulePath = (pose: AvatarPose, surface: SurfaceConfig) => { + const radiusX = surface.width / 2 + const radiusY = Math.min(radiusX, surface.height / 2) + const radiusZ = surface.depth / 2 + const straightHalf = Math.max(0, (surface.height - radiusY * 2) / 2) + const axes: Point3 = [radiusX, radiusY, radiusZ] + const top = projectedEllipsoid(pose, axes, [0, straightHalf, 0]) + const bottom = projectedEllipsoid(pose, axes, [0, -straightHalf, 0]) + if (!top || !bottom) return null + return smoothHullPath(convexHull([...ellipsePoints(top), ...ellipsePoints(bottom)])) +} + +const headPath = (pose: AvatarPose, surface: SurfaceConfig) => { + if (surface.type === 'sphere' || surface.type === 'mickey') { + const exactPath = projectedEllipsoidPath(pose, surface) + if (exactPath) return exactPath + } + + if (surface.type === 'capsule') { + const exactPath = projectedCapsulePath(pose, surface) + if (exactPath) return exactPath + } + + if (surface.type === 'cylinder') return projectedCylinderPath(pose, surface) + if (surface.type === 'cursor') return projectedCursorBodyPath(pose, surface) + if (surface.type === 'cone') return projectedConePath(pose, surface) + if (surface.type === 'cube') return projectedCubePath(pose, surface) + if (surface.type === 'diamond') return projectedDiamondPath(pose, surface) + + const key = surfaceCacheKey(surface) + let localSamples = headSamplesCache.get(key) + if (!localSamples) { + localSamples = Array.from({ length: HEAD_LATITUDE_SAMPLES }, (_, latitudeIndex) => { + const latitude = -Math.PI / 2 + (latitudeIndex / (HEAD_LATITUDE_SAMPLES - 1)) * Math.PI + return Array.from({ length: HEAD_LONGITUDE_SAMPLES }, (_, longitudeIndex) => { + const longitude = -Math.PI + (longitudeIndex / (HEAD_LONGITUDE_SAMPLES - 1)) * Math.PI * 2 + return surfacePointAt(surface, longitude, latitude) + }) + }).flat() + cacheSurfaceValue(headSamplesCache, key, localSamples) + } + const projectedSamples = localSamples.map(sample => + project(rotateWithQuaternion(pose.orientation, sample), pose.expression.perspective) + ) + return path(convexHull(projectedSamples)) +} + +const accessoryPath = (pose: AvatarPose, node: BodyNode) => { + const key = surfaceCacheKey(node.surface) + let localSamples = accessorySamplesCache.get(key) + if (!localSamples) { + localSamples = Array.from({ length: 17 }, (_, latitudeIndex) => { + const latitude = -Math.PI / 2 + (latitudeIndex / 16) * Math.PI + return Array.from({ length: 49 }, (_, longitudeIndex) => { + const longitude = -Math.PI + (longitudeIndex / 48) * Math.PI * 2 + return surfacePointAt(node.surface, longitude, latitude) + }) + }).flat() + cacheSurfaceValue(accessorySamplesCache, key, localSamples) + } + + const localOrientation = quaternionFromEuler( + radians(node.rotation[0]), + radians(node.rotation[1]), + radians(node.rotation[2]) + ) + const projected = localSamples.map(point => { + const locallyRotated = rotateWithQuaternion(localOrientation, point) + const positioned: Point3 = [ + locallyRotated[0] + node.position[0], + locallyRotated[1] + node.position[1], + locallyRotated[2] + node.position[2], + ] + return project(rotateWithQuaternion(pose.orientation, positioned), pose.expression.perspective) + }) + const hull = convexHull(projected) + if ( + (node.surface.type === 'cube' || node.surface.type === 'diamond') && + node.surface.roundness <= 0 + ) { + return path(hull) + } + return smoothClosedPath(densifyClosedPoints(hull)) +} + +const ACCESSORY_FRONT_CROSSING_RATIO = 0.1 + +const accessoryCameraDepthRadius = (pose: AvatarPose, node: BodyNode) => { + const localOrientation = quaternionFromEuler( + radians(node.rotation[0]), + radians(node.rotation[1]), + radians(node.rotation[2]) + ) + const cameraDepthByAxis = ( + [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ] as Point3[] + ).map( + axis => rotateWithQuaternion(pose.orientation, rotateWithQuaternion(localOrientation, axis))[2] + ) + return Math.hypot( + cameraDepthByAxis[0] * (node.surface.width / 2), + cameraDepthByAxis[1] * (node.surface.height / 2), + cameraDepthByAxis[2] * (node.surface.depth / 2) + ) +} + +const accessoryLayers = (pose: AvatarPose, nodes: BodyNode[]) => { + const layers = nodes + .map(node => { + const depth = rotateWithQuaternion(pose.orientation, node.position)[2] + return { + id: node.id, + path: accessoryPath(pose, node), + depth, + front: depth > accessoryCameraDepthRadius(pose, node) * ACCESSORY_FRONT_CROSSING_RATIO, + } + }) + .sort((left, right) => left.depth - right.depth) + return { + backPaths: layers.filter(layer => !layer.front).map(layer => layer.path), + frontPaths: layers.filter(layer => layer.front).map(layer => layer.path), + backNodeIds: layers.filter(layer => !layer.front).map(layer => layer.id), + frontNodeIds: layers.filter(layer => layer.front).map(layer => layer.id), + } +} + +export const renderAvatar = ( + pose: AvatarPose, + surface: SurfaceConfig, + blink = 1, + options: RenderAvatarOptions = {} +): AvatarGeometry => { + const leftSamples = eyePoints(pose, surface, -1, blink) + const rightSamples = eyePoints(pose, surface, 1, blink) + const left = leftSamples.map(sample => sample.point) + const right = rightSamples.map(sample => sample.point) + const accessories = accessoryLayers(pose, options.bodyNodes ?? []) + const compositePaths = compositeBackPaths(pose, surface) + return { + backPaths: [...compositePaths, ...accessories.backPaths], + frontPaths: accessories.frontPaths, + backNodeIds: [...compositePaths.map(() => null), ...accessories.backNodeIds], + frontNodeIds: accessories.frontNodeIds, + headPath: headPath(pose, surface), + leftPath: path(left), + rightPath: path(right), + leftVisible: leftSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0, + rightVisible: rightSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0, + wirePaths: options.includeWire === false ? [] : wirePaths(pose, surface), + } +} diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..7abab67 --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,514 @@ +import { + createContext, + createElement, + useContext, + useEffect, + useState, + type ReactNode, +} from 'react' +import { chinese, translateChineseDynamicText } from './i18n.zh' + +export type StudioLanguage = 'en' | 'fr' | 'zh-CN' + +const STUDIO_LANGUAGE_STORAGE_KEY = 'avatar-studio-language' + +const isStudioLanguage = (value: string | null): value is StudioLanguage => + value === 'en' || value === 'fr' || value === 'zh-CN' + +const readStoredStudioLanguage = (): StudioLanguage => { + if (typeof window === 'undefined') return 'en' + try { + const storedLanguage = window.localStorage.getItem(STUDIO_LANGUAGE_STORAGE_KEY) + return isStudioLanguage(storedLanguage) ? storedLanguage : 'en' + } catch { + return 'en' + } +} + +const english: Record = { + 'Playground de l’avatar': 'Avatar playground', + Arrêté: 'Stopped', + 'Aperçu procédural en direct': 'Live procedural preview', + 'Aperçu de l’avatar': 'Avatar preview', + 'Choisissez une animation exportée pour la lancer.': 'Choose an exported animation to play it.', + Lecture: 'Playback', + 'Contrôlez l’animation active.': 'Control the active animation.', + Lire: 'Play', + Arrêter: 'Stop', + 'API JavaScript': 'JavaScript API', + 'Le module réutilisable est disponible dans avatar.js.': + 'The reusable module is available as avatar.js.', + 'Ouvrez cette démo directement, ou servez le dossier localement pour importer avatar.js depuis un autre module.': + 'Open this demo directly, or serve the folder locally to import avatar.js from another module.', + 'Langue de l’interface': 'Interface language', + 'Nom de l’avatar': 'Avatar name', + sélecteur: 'picker', + hexadécimal: 'hex value', + 'Lier width': 'Link widths', + 'Lier height': 'Link heights', + 'Lier size': 'Link sizes', + 'Lier la position des yeux': 'Link eye positions', + 'Glisser horizontalement pour modifier': 'Drag horizontally to adjust', + 'En lecture': 'Playing', + 'En pause': 'Paused', + 'Réinitialiser la rotation de la tête': 'Reset head rotation', + 'Réinitialiser la position et la rotation de': 'Reset position and rotation of', + 'Avatar procédural': 'Procedural avatar', + 'Gizmo de rotation': 'Rotation gizmo', + 'Retour aux expressions': 'Back to expressions', + 'Retour aux animations': 'Back to animations', + 'Retour au studio': 'Back to studio', + 'Preset en mémoire': 'Saved preset', + 'Nouvelle expression': 'New expression', + 'L’avatar à gauche affiche cette expression en direct.': + 'The avatar on the left previews this expression live.', + Corps: 'Body', + 'Apparence et orientation générale de l’avatar.': 'General avatar appearance and orientation.', + 'Couleur du corps': 'Body color', + 'Rotation de la tête': 'Head rotation', + Yeux: 'Eyes', + 'Forme, placement et orientation propres au regard.': 'Eye shape, placement and orientation.', + 'Couleur des yeux': 'Eye color', + Largeur: 'Width', + Hauteur: 'Height', + Échelle: 'Scale', + 'Taille proportionnelle': 'Proportional size', + 'Œil gauche': 'Left eye', + 'Œil droit': 'Right eye', + 'Position et espacement': 'Position and spacing', + Horizontale: 'Horizontal', + Verticale: 'Vertical', + Espacement: 'Spacing', + 'Rotation locale': 'Local rotation', + Projection: 'Projection', + 'Perspective appliquée à la surface active.': 'Perspective applied to the active surface.', + Perspective: 'Perspective', + Supprimer: 'Delete', + Enregistrer: 'Save', + 'Construction du corps': 'Body construction', + 'Choisis la forme principale puis assemble les primitives autour d’elle.': + 'Choose the primary shape, then assemble primitives around it.', + 'Choisir un avatar': 'Choose an avatar', + 'Double-clic pour modifier': 'Double-click to edit', + 'Nouvel avatar': 'New avatar', + Modifier: 'Edit', + 'Mode d’édition': 'Editing mode', + Pose: 'Pose', + Expressions: 'Expressions', + Animations: 'Animations', + Exporter: 'Export', + 'Exporter l’avatar': 'Export avatar', + 'Télécharge un composant autonome avec les animations de ton choix.': + 'Download a standalone component with the animations you choose.', + 'Avatar sélectionné': 'Selected avatar', + Format: 'Format', + 'Choisis l’intégration correspondant à ton projet.': + 'Choose the integration that matches your project.', + 'Composant TSX autonome': 'Standalone TSX component', + 'Package React local (.zip)': 'Local React package (.zip)', + 'Module JavaScript': 'JavaScript module', + 'Module ES autonome': 'Standalone ES module', + 'Projet HTML + module JS (.zip)': 'HTML project + JS module (.zip)', + sélectionnées: 'selected', + 'Animations à exporter': 'Animations to export', + 'Tout sélectionner': 'Select all', + 'Tout désélectionner': 'Deselect all', + 'Télécharger le composant TSX': 'Download TSX component', + 'Télécharger le package React': 'Download React package', + 'Télécharger le module': 'Download module', + Snapshot: 'Snapshot', + 'Mode photo': 'Photo Mode', + 'Capture une image statique de l’avatar.': 'Capture a static image of the avatar.', + 'Les options de capture seront configurées ici.': 'Snapshot options will be configured here.', + 'Aperçu du Snapshot': 'Snapshot preview', + 'Aperçu du mode photo': 'Photo Mode preview', + 'Arrière-plan': 'Background', + 'Choisis un fond transparent, uni ou en dégradé.': + 'Choose a transparent, solid or gradient background.', + Style: 'Style', + Transparent: 'Transparent', + Uni: 'Solid', + 'Dégradé linéaire': 'Linear gradient', + 'Dégradé radial': 'Radial gradient', + 'Style d’arrière-plan': 'Background style', + Couleur: 'Color', + Départ: 'Start', + Arrivée: 'End', + Définition: 'Resolution', + 'Dimensions inscrites dans le fichier SVG.': 'Dimensions embedded in the SVG file.', + 'Dimensions du fichier exporté.': 'Dimensions of the exported file.', + 'Définition du Snapshot': 'Snapshot resolution', + 'Définition du mode photo': 'Photo Mode resolution', + 'Télécharger le Snapshot SVG': 'Download SVG snapshot', + 'Télécharger en SVG': 'Download SVG', + 'Télécharger en PNG': 'Download PNG', + 'Format d’export': 'Export format', + 'Format d’export du mode photo': 'Photo Mode export format', + 'Choisis le type de fichier généré par le mode photo.': + 'Choose the file type generated by Photo Mode.', + 'Prendre une photo': 'Take a picture', + 'Informations sur le mode photo': 'Photo Mode information', + 'Tu peux modifier le format, le fond et la définition du mode photo dans Export.': + 'You can change the format, background and resolution for Photo Mode in Export.', + 'Projet du Studio': 'Studio project', + 'Transfère tous les avatars, expressions et animations vers un autre navigateur.': + 'Transfer every avatar, expression and animation to another browser.', + 'Télécharger le projet JSON': 'Download JSON project', + 'Importer un projet JSON': 'Import JSON project', + 'Ce fichier ne contient pas un projet Avatar Studio valide et compatible.': + 'This file does not contain a valid, compatible Avatar Studio project.', + 'Importer ce projet ?': 'Import this project?', + 'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.': + 'The current local project will be replaced with the avatars, expressions, animations and playback state from this file.', + 'Le projet n’a pas pu être enregistré dans ce navigateur. Libère de l’espace puis réessaie.': + 'The project could not be saved in this browser. Free up some space and try again.', + Importer: 'Import', + 'Construction, forme et couleur de la tête de l’avatar.': + 'Build, shape and color of the avatar head.', + 'Une forme principale porte les yeux. Les autres primitives se placent autour d’elle.': + 'One primary shape carries the eyes. Other primitives are placed around it.', + 'Forme principale': 'Primary shape', + 'porte les yeux': 'carries the eyes', + 'Ajouter une forme': 'Add a shape', + Dupliquer: 'Duplicate', + 'Gizmo local': 'Local gizmo', + 'Déplacer dans le plan de la caméra': 'Move in camera plane', + Transformer: 'Transform', + copie: 'copy', + 'Glisse un axe pour déplacer la forme, ou un anneau pour la faire tourner.': + 'Drag an axis to move the shape, or a ring to rotate it.', + Profondeur: 'Depth', + 'Position locale': 'Local position', + 'Cette surface est la référence du visage et porte les yeux.': + 'This surface is the face reference and carries the eyes.', + 'Rondeur des arêtes': 'Edge roundness', + Rondeur: 'Roundness', + 'Rondeur morphologique': 'Morph roundness', + 'Rondeur de la pointe': 'Tip roundness', + 'Rondeur de la base': 'Base roundness', + 'Rondeur globale': 'Global roundness', + 'Rondeur pointe': 'Tip roundness', + 'Rondeur base': 'Base roundness', + 'Couleur de base utilisée par les poses et les expressions.': + 'Base color used by poses and expressions.', + 'Forme, placement, orientation et couleur du regard par défaut.': + 'Default eye shape, placement, orientation and color.', + 'Définis l’identité du regard de cet avatar. Les poses s’ajoutent ensuite à cette base.': + 'Define this avatar’s default eyes. Poses are then applied on top of this base.', + 'Coordonnées propres à l’avatar, indépendantes des poses.': + 'Avatar-specific coordinates, independent from poses.', + 'Inclinaison par défaut propre à chaque œil.': 'Default tilt for each eye.', + 'Orientation et apparence générale de la pose.': 'General pose orientation and appearance.', + 'La pose peut remplacer temporairement la couleur de l’avatar.': + 'The pose can temporarily override the avatar color.', + 'Reprendre la couleur de l’avatar': 'Use avatar color', + 'Les libellés ↔ sont scrubbables, comme dans Figma.': + 'Labels marked ↔ can be scrubbed, like in Figma.', + 'Forme, placement, orientation et couleur du regard.': + 'Eye shape, placement, orientation and color.', + 'Coordonnées communes projetées sur la forme choisie.': + 'Shared coordinates projected onto the selected shape.', + 'Inclinaison propre à chaque œil.': 'Tilt applied to each eye.', + 'Perspective et repères appliqués à la surface active.': + 'Perspective and guides applied to the active surface.', + 'Profondeur simulée du visage.': 'Simulated face depth.', + 'Afficher le maillage': 'Show wireframe', + Réinitialiser: 'Reset', + Mouvement: 'Motion', + 'Mouvement perpétuel': 'Perpetual motion', + 'Aucun mouvement': 'No motion', + 'Dérive lente': 'Slow drift', + 'Micro-ajustements': 'Micro-adjustments', + Tremblement: 'Shake', + 'Ajoute une légère présence ou un tremblement continu au corps.': + 'Adds a subtle living presence or a continuous shake to the body.', + 'Anime le regard par petites saccades naturelles ou par tremblement.': + 'Animates the gaze with natural micro-saccades or a continuous shake.', + 'Motion interpole les valeurs et notre moteur effectue le slerp quaternion.': + 'Motion interpolates values while the engine performs quaternion slerp.', + 'Vitesse du ressort': 'Spring speed', + 'Une animation est en cours de lecture': 'An animation is currently playing', + 'Mettez l’animation en pause avant de manipuler les paramètres de pose.': + 'Pause the animation before adjusting pose parameters.', + 'Mettez l’animation en pause avant de choisir ou personnaliser une expression.': + 'Pause the animation before selecting or customizing an expression.', + Cligner: 'Blink', + 'Expression aléatoire': 'Random expression', + 'Cycle de vie': 'Life cycle', + Réactions: 'Reactions', + 'Éditeur d’animation': 'Animation editor', + 'Modifier l’animation': 'Edit animation', + 'Nouvelle animation': 'New animation', + 'Compose les expressions, leur cadence et les clignements de cette animation.': + 'Compose the expressions, timing and blinks for this animation.', + 'Prévisualiser l’animation': 'Preview animation', + 'Arrêter l’animation': 'Stop animation', + Identité: 'Identity', + 'Nom, catégorie et comportement de lecture de l’animation.': + 'Animation name, category and playback behavior.', + Nom: 'Name', + Catégorie: 'Category', + Description: 'Description', + 'Mode de lecture': 'Playback mode', + Boucle: 'Loop', + 'Une fois': 'Once', + 'Aller-retour': 'Ping-pong', + Timeline: 'Timeline', + 'Glisse les étapes pour les réordonner, puis sélectionne-en une pour régler sa cadence.': + 'Drag steps to reorder them, then select one to adjust its timing.', + 'Ajoute une expression pour commencer.': 'Add an expression to get started.', + 'Étape sélectionnée': 'Selected step', + 'Durée visible avant de passer à l’expression suivante.': + 'Visible duration before moving to the next expression.', + 'Supprimer cette étape': 'Delete this step', + 'Temps d’affichage': 'Display time', + 'Durée de transition': 'Transition duration', + Transition: 'Transition', + Ressort: 'Spring', + Douce: 'Smooth', + Rapide: 'Snappy', + 'Ajouter une expression': 'Add expression', + 'Sélectionne un preset pour l’ajouter à la fin de la timeline.': + 'Select a preset to append it to the timeline.', + Clignements: 'Blinks', + 'Le blink fonctionne indépendamment des changements d’expression.': + 'Blinking runs independently from expression changes.', + 'Activer les clignements': 'Enable blinking', + 'Intervalle minimum': 'Minimum interval', + 'Intervalle maximum': 'Maximum interval', + 'Intervalle du clignement': 'Blink interval', + 'Durée du clignement': 'Blink duration', + animations: 'animations', + 'Chaque étape possède sa propre durée et sa propre transition.': + 'Each step has its own duration and transition.', + Désactivé: 'Disabled', + 'comportement de la timeline': 'timeline behavior', + 'Supprimer cette animation ?': 'Delete this animation?', + 'Cette action supprimera définitivement cette animation.': + 'This permanently deletes this animation.', + 'Cette animation enchaîne un pool de presets et des clignements.': + 'This animation cycles through presets and blinks.', + 'Cet état enchaîne un pool de presets et des clignements.': + 'This animation cycles through presets and blinks.', + 'Expressions de l’animation': 'Animation expressions', + 'Les presets sont joués dans cet ordre, puis la boucle recommence.': + 'Presets play in this order, then the loop starts again.', + 'Logique de clignement': 'Blink behavior', + 'Le rythme reste naturel grâce à un intervalle légèrement aléatoire.': + 'A slightly randomized interval keeps the rhythm natural.', + 'Premier clignement': 'First blink', + 'après le lancement': 'after launch', + Intervalle: 'Interval', + 'tirage aléatoire': 'randomized', + Durée: 'Duration', + 'fermeture et ouverture': 'close and open', + 'Changement d’expression': 'Expression change', + 'cadence de l’animation': 'animation tempo', + 'Détails de l’animation': 'Animation details', + 'Afficher les détails de l’animation': 'Show animation details', + 'Masquer les détails de l’animation': 'Hide animation details', + Relancer: 'Restart', + Lancer: 'Play', + Pause: 'Pause', + Reprendre: 'Resume', + Annuler: 'Cancel', + 'Supprimer cette expression ?': 'Delete this expression?', + 'Cette action retirera définitivement le preset de la bibliothèque de cet avatar.': + 'This permanently removes the preset from this avatar’s library.', + 'Cette expression sera aussi retirée des animations suivantes :': + 'This expression will also be removed from the following animations:', + 'Si une animation ne contient que cette expression, l’expression de repli lui sera assignée pour qu’elle reste jouable.': + 'If an animation only contains this expression, the fallback expression will be assigned so it remains playable.', + 'Le corps, les expressions et les animations propres à cet avatar seront définitivement supprimés. La bibliothèque de base sera conservée.': + 'This avatar’s body, expressions and animations will be permanently deleted. The base library will be preserved.', + 'Glisse sur la surface pour orienter la tête. Les anneaux du gizmo contrôlent X, Y et Z.': + 'Drag on the surface to orient the head. The gizmo rings control X, Y and Z.', + Sphère: 'Sphere', + Curseur: 'Cursor', + Cylindre: 'Cylinder', + Cône: 'Cone', + Diamant: 'Diamond', + 'Yeux presque fermés, respiration lente et expression de sommeil.': + 'Nearly closed eyes, slow breathing and a sleepy expression.', + 'Animation courte de réveil avant retour vers une expression neutre.': + 'Short waking animation before returning to a neutral expression.', + 'Séquence courte de réveil avant retour vers une expression neutre.': + 'Short waking animation before returning to a neutral expression.', + 'Micro-mouvements lents, expressions 00 et 08, clignement rare.': + 'Slow micro-movements, expressions 00 and 08, infrequent blinking.', + 'Expressions 10, 01 et 19, regard stable et clignement attentif.': + 'Expressions 10, 01 and 19, steady gaze and attentive blinking.', + 'Regard haut et latéral, expressions asymétriques et changements fréquents.': + 'Upward side gaze, asymmetric expressions and frequent changes.', + 'Balayage rapide et changements très fréquents.': 'Fast scanning and very frequent changes.', + 'Rythme régulier, regard concentré et micro-variations.': + 'Steady rhythm, focused gaze and subtle variations.', + 'Grandes expressions positives et mouvements rapides.': + 'Big positive expressions and fast movements.', + 'Inclinaisons et rotations rapides pour simuler un agent en mouvement.': + 'Fast tilts and rotations to simulate a moving agent.', + 'Rythme régulier et expressions concentrées.': 'Steady rhythm and focused expressions.', + 'Grandes expressions et transitions rapides.': 'Big expressions and fast transitions.', + 'Inclinaisons et forte asymétrie.': 'Tilts and strong asymmetry.', + sleeping: 'sleeping', + waking: 'waking', + idle: 'idle', + listening: 'listening', + thinking: 'thinking', + searching: 'searching', + working: 'working', + excited: 'excited', + surprised: 'surprised', + suspicious: 'suspicious', + angry: 'angry', + drowsy: 'drowsy', + happy: 'happy', + curious: 'curious', + confused: 'confused', + bored: 'bored', + proud: 'proud', + shy: 'shy', + sad: 'sad', + laughing: 'laughing', + scared: 'scared', + playful: 'playful', + celebrate: 'celebrate', + orbit: 'orbit', + radar: 'radar', + progress: 'progress', + spawning: 'spawning', + humming: 'humming', + loading: 'loading', + dictating: 'dictating', + writing: 'writing', + sending: 'sending', + receiving: 'receiving', + uploading: 'uploading', + notifying: 'notifying', + alerting: 'alerting', + dragging: 'dragging', + bouncing: 'bouncing', + 'powering-down': 'powering down', +} + +const frenchStates: Record = { + loop: 'boucle', + once: 'une fois', + pingPong: 'aller-retour', + Custom: 'Personnalisé', + 'Untitled animation': 'Animation sans titre', + // Compatibilité avec les animations intégrées déjà persistées avant le renommage. + 'Untitled sequence': 'Animation sans titre', + 'Cet état enchaîne un pool de presets et des clignements.': + 'Cette animation enchaîne un pool de presets et des clignements.', + 'Séquence courte de réveil avant retour vers une expression neutre.': + 'Animation courte de réveil avant retour vers une expression neutre.', + sleeping: 'sommeil', + waking: 'réveil', + idle: 'au repos', + listening: 'écoute', + thinking: 'réflexion', + searching: 'recherche', + working: 'travail', + excited: 'enthousiaste', + surprised: 'surpris', + suspicious: 'méfiant', + angry: 'en colère', + drowsy: 'somnolent', + happy: 'heureux', + curious: 'curieux', + confused: 'confus', + bored: 'ennuyé', + proud: 'fier', + shy: 'timide', + sad: 'triste', + laughing: 'rire', + scared: 'effrayé', + playful: 'joueur', + celebrate: 'célébration', + orbit: 'orbite', + radar: 'radar', + progress: 'progression', + spawning: 'apparition', + humming: 'fredonnement', + loading: 'chargement', + dictating: 'dictée', + writing: 'écriture', + sending: 'envoi', + receiving: 'réception', + uploading: 'téléversement', + notifying: 'notification', + alerting: 'alerte', + dragging: 'glissement', + bouncing: 'rebond', + 'powering-down': 'extinction', +} + +const dynamicTranslations: [RegExp, string][] = [ + [/^Modifier l’expression (.+)$/, 'Edit expression $1'], + [/^Modifier (.+)$/, 'Edit $1'], + [/^Supprimer (.+) \?$/, 'Delete $1?'], + [/^Ajouter une forme · (.+)$/, 'Add a shape · $1'], + [/^Réinitialiser la position et la rotation de (.+)$/, 'Reset position and rotation of $1'], + [/^Animation en cours : (.+)$/, 'Active animation: $1'], + [/^Mettre (.+) en pause$/, 'Pause $1'], + [/^Reprendre (.+)$/, 'Resume $1'], + [/^Arrêter (.+)$/, 'Stop $1'], + [/^(.+) expressions$/, '$1 expressions'], +] + +export const translateStudioText = (text: string, language: StudioLanguage) => { + if (language === 'fr') return frenchStates[text] ?? text + if (language === 'zh-CN') { + const exact = chinese[text] + if (exact) return exact + const dynamic = translateChineseDynamicText(text) + if (dynamic) return dynamic + return Object.entries(chinese) + .sort(([left], [right]) => right.length - left.length) + .reduce( + (translated, [source, replacement]) => translated.replaceAll(source, replacement), + text + ) + } + const exact = english[text] + if (exact) return exact + for (const [pattern, replacement] of dynamicTranslations) { + if (pattern.test(text)) return text.replace(pattern, replacement) + } + return Object.entries(english) + .sort(([left], [right]) => right.length - left.length) + .reduce((translated, [source, replacement]) => translated.replaceAll(source, replacement), text) +} + +type StudioLanguageContextValue = { + language: StudioLanguage + setLanguage: (language: StudioLanguage) => void + t: (text: string) => string +} + +const StudioLanguageContext = createContext(null) + +export function StudioLanguageProvider({ children }: { children: ReactNode }) { + const [language, setLanguage] = useState(readStoredStudioLanguage) + + useEffect(() => { + try { + window.localStorage.setItem(STUDIO_LANGUAGE_STORAGE_KEY, language) + } catch { + // The studio remains usable when browser storage is unavailable. + } + }, [language]) + + return createElement( + StudioLanguageContext.Provider, + { value: { language, setLanguage, t: text => translateStudioText(text, language) } }, + children + ) +} + +export const useStudioLanguage = () => { + const context = useContext(StudioLanguageContext) + if (!context) throw new Error('useStudioLanguage must be used inside StudioLanguageProvider') + return context +} diff --git a/src/i18n.zh.ts b/src/i18n.zh.ts new file mode 100644 index 0000000..fba2909 --- /dev/null +++ b/src/i18n.zh.ts @@ -0,0 +1,377 @@ +export const chinese: Record = { + 'Playground de l’avatar': '头像游乐场', + Arrêté: '已停止', + 'Aperçu procédural en direct': '实时程序化预览', + 'Aperçu de l’avatar': '头像预览', + 'Choisissez une animation exportée pour la lancer。': '选择一个已导出的动画以播放。', + 'Choisissez une animation exportée pour la lancer.': '选择一个已导出的动画以播放。', + Lecture: '播放', + 'Contrôlez l’animation active.': '控制当前动画。', + Lire: '播放', + Arrêter: '停止', + 'API JavaScript': 'JavaScript API', + 'Le module réutilisable est disponible dans avatar.js.': '可复用模块位于 avatar.js。', + 'Ouvrez cette démo directement, ou servez le dossier localement pour importer avatar.js depuis un autre module.': + '可直接打开此演示,或在本地启动该目录,以便从其他模块导入 avatar.js。', + 'Langue de l’interface': '界面语言', + 'Nom de l’avatar': '头像名称', + sélecteur: '选择器', + hexadécimal: '十六进制值', + 'Lier width': '关联宽度', + 'Lier height': '关联高度', + 'Lier size': '关联尺寸', + 'Lier la position des yeux': '关联眼睛位置', + 'Glisser horizontalement pour modifier': '水平拖动以调整', + 'En lecture': '播放中', + 'En pause': '已暂停', + 'Réinitialiser la rotation de la tête': '重置头部旋转', + 'Réinitialiser la position et la rotation de': '重置位置和旋转:', + 'Avatar procédural': '程序化头像', + 'Gizmo de rotation': '旋转控制器', + 'Retour aux expressions': '返回表情', + 'Retour aux animations': '返回动画', + 'Retour au studio': '返回工作室', + 'Preset en mémoire': '已保存预设', + 'Nouvelle expression': '新建表情', + 'L’avatar à gauche affiche cette expression en direct.': '左侧头像会实时预览此表情。', + Corps: '身体', + 'Apparence et orientation générale de l’avatar.': '头像的整体外观和方向。', + 'Couleur du corps': '身体颜色', + 'Rotation de la tête': '头部旋转', + Yeux: '眼睛', + 'Forme, placement et orientation propres au regard.': '眼睛的形状、位置和方向。', + 'Couleur des yeux': '眼睛颜色', + Largeur: '宽度', + Hauteur: '高度', + Échelle: '缩放', + 'Taille proportionnelle': '等比尺寸', + 'Œil gauche': '左眼', + 'Œil droit': '右眼', + 'Position et espacement': '位置和间距', + Horizontale: '水平', + Verticale: '垂直', + Espacement: '间距', + 'Rotation locale': '局部旋转', + Projection: '投影', + 'Perspective appliquée à la surface active.': '应用于当前表面的透视。', + Perspective: '透视', + Supprimer: '删除', + Enregistrer: '保存', + 'Construction du corps': '身体构建', + 'Choisis la forme principale puis assemble les primitives autour d’elle.': + '选择主形状,然后在其周围组合基础形状。', + 'Choisir un avatar': '选择头像', + 'Double-clic pour modifier': '双击编辑', + 'Nouvel avatar': '新建头像', + Modifier: '编辑', + 'Mode d’édition': '编辑模式', + Pose: '姿势', + Expressions: '表情', + Animations: '动画', + Exporter: '导出', + Export: '导出', + 'Exporter l’avatar': '导出头像', + 'Télécharge un composant autonome avec les animations de ton choix.': + '下载包含所选动画的独立组件。', + 'Avatar sélectionné': '已选头像', + Format: '格式', + 'Choisis l’intégration correspondant à ton projet.': '选择适合项目的集成方式。', + 'Composant TSX autonome': '独立 TSX 组件', + 'Package React local (.zip)': '本地 React 包(.zip)', + 'Module JavaScript': 'JavaScript 模块', + 'Module ES autonome': '独立 ES 模块', + 'Projet HTML + module JS (.zip)': 'HTML 项目 + JS 模块(.zip)', + sélectionnées: '已选择', + 'Animations à exporter': '要导出的动画', + 'Tout sélectionner': '全选', + 'Tout désélectionner': '取消全选', + 'Télécharger le composant TSX': '下载 TSX 组件', + 'Télécharger le package React': '下载 React 包', + 'Télécharger le module': '下载模块', + Snapshot: '快照', + 'Mode photo': '照片模式', + 'Capture une image statique de l’avatar.': '捕获头像的静态图像。', + 'Les options de capture seront configurées ici.': '快照选项将在此配置。', + 'Aperçu du Snapshot': '快照预览', + 'Aperçu du mode photo': '照片模式预览', + 'Arrière-plan': '背景', + 'Choisis un fond transparent, uni ou en dégradé.': '选择透明、纯色或渐变背景。', + Style: '样式', + Transparent: '透明', + Uni: '纯色', + 'Dégradé linéaire': '线性渐变', + 'Dégradé radial': '径向渐变', + 'Style d’arrière-plan': '背景样式', + Couleur: '颜色', + Départ: '起始颜色', + Arrivée: '结束颜色', + Définition: '分辨率', + 'Dimensions inscrites dans le fichier SVG.': '写入 SVG 文件的尺寸。', + 'Dimensions du fichier exporté.': '导出文件的尺寸。', + 'Définition du Snapshot': '快照分辨率', + 'Définition du mode photo': '照片模式分辨率', + 'Télécharger le Snapshot SVG': '下载 SVG 快照', + 'Télécharger en SVG': '下载 SVG', + 'Télécharger en PNG': '下载 PNG', + 'Format d’export': '导出格式', + 'Format d’export du mode photo': '照片模式导出格式', + 'Choisis le type de fichier généré par le mode photo.': '选择照片模式生成的文件类型。', + 'Prendre une photo': '拍照', + 'Informations sur le mode photo': '照片模式信息', + 'Tu peux modifier le format, le fond et la définition du mode photo dans Export.': + '你可以在“导出”中更改照片模式的格式、背景和分辨率。', + 'Projet du Studio': '工作室项目', + 'Transfère tous les avatars, expressions et animations vers un autre navigateur.': + '将所有头像、表情和动画传输到另一个浏览器。', + 'Télécharger le projet JSON': '下载 JSON 项目', + 'Importer un projet JSON': '导入 JSON 项目', + 'Ce fichier ne contient pas un projet Avatar Studio valide et compatible.': + '此文件不包含有效且兼容的头像工作室项目。', + 'Importer ce projet ?': '导入此项目?', + 'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.': + '当前本地项目将被此文件中的头像、表情、动画和播放状态替换。', + 'Le projet n’a pas pu être enregistré dans ce navigateur. Libère de l’espace puis réessaie.': + '无法在此浏览器中保存项目。请释放空间后重试。', + Importer: '导入', + 'Construction, forme et couleur de la tête de l’avatar.': '头像头部的构建、形状和颜色。', + 'Une forme principale porte les yeux. Les autres primitives se placent autour d’elle.': + '主形状承载眼睛,其他基础形状围绕它放置。', + 'Forme principale': '主形状', + 'porte les yeux': '承载眼睛', + 'Ajouter une forme': '添加形状', + Dupliquer: '复制', + 'Gizmo local': '局部控制器', + 'Déplacer dans le plan de la caméra': '在相机平面中移动', + Transformer: '变换', + copie: '副本', + 'Glisse un axe pour déplacer la forme, ou un anneau pour la faire tourner.': + '拖动轴来移动形状,或拖动圆环来旋转形状。', + Profondeur: '深度', + 'Position locale': '局部位置', + 'Cette surface est la référence du visage et porte les yeux.': '此表面是面部基准并承载眼睛。', + 'Rondeur des arêtes': '边缘圆度', + Rondeur: '圆度', + 'Rondeur morphologique': '形态圆度', + 'Rondeur de la pointe': '尖端圆度', + 'Rondeur de la base': '底部圆度', + 'Rondeur globale': '整体圆度', + 'Rondeur pointe': '尖端圆度', + 'Rondeur base': '底部圆度', + 'Couleur de base utilisée par les poses et les expressions.': '姿势和表情使用的基础颜色。', + 'Forme, placement, orientation et couleur du regard par défaut.': + '默认眼睛的形状、位置、方向和颜色。', + 'Définis l’identité du regard de cet avatar. Les poses s’ajoutent ensuite à cette base.': + '定义此头像的默认眼睛,姿势将在此基础上叠加。', + 'Coordonnées propres à l’avatar, indépendantes des poses.': '头像自身的坐标,与姿势无关。', + 'Inclinaison par défaut propre à chaque œil.': '每只眼睛的默认倾斜角度。', + 'Orientation et apparence générale de la pose.': '姿势的整体方向和外观。', + 'La pose peut remplacer temporairement la couleur de l’avatar.': '姿势可以暂时覆盖头像颜色。', + 'Reprendre la couleur de l’avatar': '恢复头像颜色', + 'Les libellés ↔ sont scrubbables, comme dans Figma.': '带 ↔ 的标签可像 Figma 一样拖动调整。', + 'Forme, placement, orientation et couleur du regard.': '眼睛的形状、位置、方向和颜色。', + 'Coordonnées communes projetées sur la forme choisie.': '投影到所选形状上的共享坐标。', + 'Inclinaison propre à chaque œil.': '每只眼睛各自的倾斜角度。', + 'Perspective et repères appliqués à la surface active.': '应用于当前表面的透视和参考线。', + 'Profondeur simulée du visage.': '模拟的面部深度。', + 'Afficher le maillage': '显示网格', + Réinitialiser: '重置', + Mouvement: '运动', + Motion: '运动', + 'Mouvement perpétuel': '持续运动', + 'Aucun mouvement': '无运动', + 'Dérive lente': '缓慢漂移', + 'Micro-ajustements': '微调', + Tremblement: '抖动', + 'Ajoute une légère présence ou un tremblement continu au corps.': + '为身体添加轻微的生命感或持续抖动。', + 'Anime le regard par petites saccades naturelles ou par tremblement.': + '通过自然的微小跳动或抖动让眼神动起来。', + 'Motion interpole les valeurs et notre moteur effectue le slerp quaternion.': + 'Motion 对数值进行插值,引擎执行四元数球面插值。', + 'Vitesse du ressort': '弹簧速度', + 'Une animation est en cours de lecture': '动画正在播放', + 'Mettez l’animation en pause avant de manipuler les paramètres de pose.': + '调整姿势参数前请暂停动画。', + 'Mettez l’animation en pause avant de choisir ou personnaliser une expression.': + '选择或自定义表情前请暂停动画。', + Cligner: '眨眼', + 'Expression aléatoire': '随机表情', + 'Cycle de vie': '生命周期', + Réactions: '反应', + 'Éditeur d’animation': '动画编辑器', + 'Modifier l’animation': '编辑动画', + 'Nouvelle animation': '新建动画', + 'Compose les expressions, leur cadence et les clignements de cette animation.': + '组合此动画的表情、节奏和眨眼。', + 'Prévisualiser l’animation': '预览动画', + 'Arrêter l’animation': '停止动画', + Identité: '基本信息', + 'Nom, catégorie et comportement de lecture de l’animation.': '动画的名称、分类和播放行为。', + Nom: '名称', + Catégorie: '分类', + Description: '描述', + 'Mode de lecture': '播放模式', + Boucle: '循环', + 'Une fois': '一次', + 'Aller-retour': '往返', + Timeline: '时间轴', + 'Glisse les étapes pour les réordonner, puis sélectionne-en une pour régler sa cadence.': + '拖动步骤以重新排序,然后选择一个步骤调整时间。', + 'Ajoute une expression pour commencer.': '添加一个表情以开始。', + 'Étape sélectionnée': '已选步骤', + 'Durée visible avant de passer à l’expression suivante.': '切换到下一个表情前的显示时长。', + 'Supprimer cette étape': '删除此步骤', + 'Temps d’affichage': '显示时间', + 'Durée de transition': '过渡时长', + Transition: '过渡', + Ressort: '弹簧', + Douce: '平滑', + Rapide: '迅速', + 'Ajouter une expression': '添加表情', + 'Sélectionne un preset pour l’ajouter à la fin de la timeline.': + '选择一个预设并添加到时间轴末尾。', + Clignements: '眨眼', + 'Le blink fonctionne indépendamment des changements d’expression.': '眨眼独立于表情变化运行。', + 'Activer les clignements': '启用眨眼', + 'Intervalle minimum': '最小间隔', + 'Intervalle maximum': '最大间隔', + 'Intervalle du clignement': '眨眼间隔', + 'Durée du clignement': '眨眼时长', + animations: '动画', + 'Chaque étape possède sa propre durée et sa propre transition.': + '每个步骤都有自己的持续时间和过渡。', + Désactivé: '已禁用', + 'comportement de la timeline': '时间轴行为', + 'Supprimer cette animation ?': '删除此动画?', + 'Cette action supprimera définitivement cette animation.': '此操作将永久删除该动画。', + 'Cette animation enchaîne un pool de presets et des clignements.': + '此动画依次播放一组预设并执行眨眼。', + 'Cet état enchaîne un pool de presets et des clignements.': '此动画依次播放一组预设并执行眨眼。', + 'Expressions de l’animation': '动画表情', + 'Les presets sont joués dans cet ordre, puis la boucle recommence.': + '预设按此顺序播放,然后重新开始循环。', + 'Logique de clignement': '眨眼行为', + 'Le rythme reste naturel grâce à un intervalle légèrement aléatoire.': + '略带随机性的间隔使节奏保持自然。', + 'Premier clignement': '首次眨眼', + 'après le lancement': '启动后', + Intervalle: '间隔', + 'tirage aléatoire': '随机', + Durée: '时长', + 'fermeture et ouverture': '闭合与睁开', + 'Changement d’expression': '表情变化', + 'cadence de l’animation': '动画节奏', + 'Détails de l’animation': '动画详情', + 'Afficher les détails de l’animation': '显示动画详情', + 'Masquer les détails de l’animation': '隐藏动画详情', + Relancer: '重新开始', + Lancer: '播放', + Pause: '暂停', + Reprendre: '继续', + Annuler: '取消', + 'Supprimer cette expression ?': '删除此表情?', + 'Cette action retirera définitivement le preset de la bibliothèque de cet avatar.': + '此操作将从该头像的库中永久删除该预设。', + 'Cette expression sera aussi retirée des animations suivantes :': '此表情也会从以下动画中移除:', + 'Si une animation ne contient que cette expression, l’expression de repli lui sera assignée pour qu’elle reste jouable.': + '如果动画只包含此表情,将为其分配备用表情以保持可播放。', + 'Le corps, les expressions et les animations propres à cet avatar seront définitivement supprimés. La bibliothèque de base sera conservée.': + '此头像的身体、表情和动画将被永久删除,基础库将被保留。', + 'Glisse sur la surface pour orienter la tête. Les anneaux du gizmo contrôlent X, Y et Z.': + '在表面上拖动以调整头部方向。控制器圆环分别控制 X、Y 和 Z。', + Sphère: '球体', + Cube: '立方体', + Capsule: '胶囊体', + Curseur: '光标', + Cylindre: '圆柱体', + Cône: '圆锥体', + Diamant: '菱形体', + 'Yeux presque fermés, respiration lente et expression de sommeil.': + '双眼几乎闭合,呼吸缓慢,呈现睡眠表情。', + 'Animation courte de réveil avant retour vers une expression neutre.': + '短暂的醒来动画,然后恢复中性表情。', + 'Séquence courte de réveil avant retour vers une expression neutre.': + '短暂的醒来动画,然后恢复中性表情。', + 'Micro-mouvements lents, expressions 00 et 08, clignement rare.': + '缓慢微动,使用表情 00 和 08,偶尔眨眼。', + 'Expressions 10, 01 et 19, regard stable et clignement attentif.': + '使用表情 10、01 和 19,视线稳定并专注眨眼。', + 'Regard haut et latéral, expressions asymétriques et changements fréquents.': + '向上侧视,不对称表情并频繁变化。', + 'Balayage rapide et changements très fréquents.': '快速扫视并非常频繁地变化。', + 'Rythme régulier, regard concentré et micro-variations.': '节奏稳定,目光专注并有细微变化。', + 'Grandes expressions positives et mouvements rapides.': '夸张的积极表情和快速运动。', + 'Inclinaisons et rotations rapides pour simuler un agent en mouvement.': + '通过快速倾斜和旋转模拟运动中的角色。', + 'Rythme régulier et expressions concentrées.': '节奏稳定且表情专注。', + 'Grandes expressions et transitions rapides.': '夸张表情和快速过渡。', + 'Inclinaisons et forte asymétrie.': '明显倾斜和强烈不对称。', + loop: '循环', + once: '一次', + pingPong: '往返', + Custom: '自定义', + 'Untitled animation': '未命名动画', + 'Untitled sequence': '未命名动画', + sleeping: '睡眠', + waking: '醒来', + idle: '待机', + listening: '聆听', + thinking: '思考', + searching: '搜索', + working: '工作', + excited: '兴奋', + surprised: '惊讶', + suspicious: '怀疑', + angry: '生气', + drowsy: '困倦', + happy: '开心', + curious: '好奇', + confused: '困惑', + bored: '无聊', + proud: '自豪', + shy: '害羞', + sad: '悲伤', + laughing: '大笑', + scared: '害怕', + playful: '顽皮', + celebrate: '庆祝', + orbit: '环绕', + radar: '雷达', + progress: '进度', + spawning: '出现', + humming: '哼唱', + loading: '加载', + dictating: '听写', + writing: '书写', + sending: '发送', + receiving: '接收', + uploading: '上传', + notifying: '通知', + alerting: '警报', + dragging: '拖动', + bouncing: '弹跳', + 'powering-down': '关机', + Avatars: '头像', + 'React / TypeScript': 'React / TypeScript', +} + +export const translateChineseDynamicText = (text: string) => { + const rules: Array<[RegExp, (...matches: string[]) => string]> = [ + [/^Modifier l’expression (.+)$/, value => `编辑表情 ${value}`], + [/^Modifier (.+)$/, value => `编辑${value}`], + [/^Supprimer (.+) \?$/, value => `删除${value}?`], + [/^Ajouter une forme · (.+)$/, value => `添加形状 · ${value}`], + [/^Réinitialiser la position et la rotation de (.+)$/, value => `重置${value}的位置和旋转`], + [/^Animation en cours : (.+)$/, value => `当前动画:${value}`], + [/^Mettre (.+) en pause$/, value => `暂停${value}`], + [/^Reprendre (.+)$/, value => `继续${value}`], + [/^Arrêter (.+)$/, value => `停止${value}`], + [/^(.+) expressions$/, value => `${value} 个表情`], + ] + + for (const [pattern, replacement] of rules) { + const match = text.match(pattern) + if (match) return replacement(...match.slice(1)) + } + return null +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..d1d7f94 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,4 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)) diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..5bae583 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' + +import App from './App' +import './styles.css' + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/src/manipulationSession.ts b/src/manipulationSession.ts new file mode 100644 index 0000000..978af20 --- /dev/null +++ b/src/manipulationSession.ts @@ -0,0 +1,35 @@ +export type ManipulationSession = { + initial: Value + latest: Value +} + +export const beginManipulation = (initial: Value): ManipulationSession => ({ + initial, + latest: initial, +}) + +export const beginManipulationFromRenderedValue = ( + readRenderedValue: () => Value +): ManipulationSession => beginManipulation(readRenderedValue()) + +export const previewManipulation = ( + session: ManipulationSession, + next: Value, + preview?: (value: Value) => void +) => { + session.latest = next + preview?.(next) +} + +export const finishManipulation = ( + session: ManipulationSession, + outcome: 'commit' | 'cancel', + handlers: { preview: (value: Value) => void; commit: (value: Value) => void } +) => { + if (outcome === 'cancel') { + handlers.preview(session.initial) + return session.initial + } + handlers.commit(session.latest) + return session.latest +} diff --git a/src/playback.ts b/src/playback.ts new file mode 100644 index 0000000..652dc79 --- /dev/null +++ b/src/playback.ts @@ -0,0 +1,68 @@ +import { advanceSequenceCursor, type AvatarSequence } from './sequences' + +export type PlaybackTimeline = { + position: number + direction: 1 | -1 + stepDueAt: number | null + stepRemainingMs: number + blinkDueAt: number | null + blinkRemainingMs: number +} + +export const createPlaybackTimeline = (): PlaybackTimeline => ({ + position: 0, + direction: 1, + stepDueAt: null, + stepRemainingMs: 0, + blinkDueAt: null, + blinkRemainingMs: 0, +}) + +export const beginPlayback = (timeline: PlaybackTimeline, preservePosition = false) => { + return { + ...timeline, + position: preservePosition ? timeline.position : 0, + direction: preservePosition ? timeline.direction : 1, + stepDueAt: null, + blinkDueAt: null, + } satisfies PlaybackTimeline +} + +export const schedulePlaybackStep = (timeline: PlaybackTimeline, now: number, delayMs: number) => { + return { ...timeline, stepRemainingMs: delayMs, stepDueAt: now + delayMs } +} + +export const schedulePlaybackBlink = (timeline: PlaybackTimeline, now: number, delayMs: number) => { + return { ...timeline, blinkRemainingMs: delayMs, blinkDueAt: now + delayMs } +} + +export const pausePlaybackTimeline = (timeline: PlaybackTimeline, now: number) => { + return { + ...timeline, + stepRemainingMs: + timeline.stepDueAt === null + ? timeline.stepRemainingMs + : Math.max(timeline.stepDueAt - now, 0), + blinkRemainingMs: + timeline.blinkDueAt === null + ? timeline.blinkRemainingMs + : Math.max(timeline.blinkDueAt - now, 0), + stepDueAt: null, + blinkDueAt: null, + } +} + +export const advancePlaybackTimeline = (timeline: PlaybackTimeline, sequence: AvatarSequence) => { + const cursor = advanceSequenceCursor(sequence, timeline.position, timeline.direction) + return { + cursor, + timeline: { + ...timeline, + position: cursor.index, + direction: cursor.direction, + stepDueAt: null, + }, + } +} + +export const stopPlaybackTimeline = (_timeline: PlaybackTimeline) => createPlaybackTimeline() diff --git a/src/presets.ts b/src/presets.ts new file mode 100644 index 0000000..3b3a416 --- /dev/null +++ b/src/presets.ts @@ -0,0 +1,219 @@ +import type { Expression } from './geometry' + +const calibrated: number[][] = [ + [7.3, 27.8, -16.1, 24.2, 27.6, 38.9, 40.7, 54.3, -20.5, 0, 0], + [-35.6, 0.7, -8.5, 29.4, 27.3, 49.5, 49.8, 57.7, -42, 0, 0], + [-36.2, 13.1, 15.5, 44.3, 51.3, 74.2, 76, 68.7, -40.7, 0, 0], + [15.6, -16.5, -11.3, 54, 51, 49.6, 48.5, 70.9, 30.1, 0, 0], + [3.4, 13, 8.9, 42.6, 44, 17.3, 16, 57.9, 4.9, 0, 0], + [-17.7, -1.4, -8.8, 29.5, 19.2, 51.6, 41.9, 56.3, 0, 0, 90], + [14.8, 14.5, 5.5, 22.9, 22.2, 32.4, 33.4, 50.9, 39.2, 0, 0], + [25.7, 16.5, -13.5, 48.5, 48.3, 33.5, 33, 53.3, 41.3, 61.3, -80], + [-22.8, -15.9, 6.2, 44.5, 43.9, 32.3, 24.6, 54.9, -42, -60.9, 69.2], + [-11.6, 8.3, -12.7, 42.5, 22.1, 41.8, 22.2, 61.7, 12.3, 0, 0], + [20.3, 7, 8.7, 30.2, 28.1, 48.8, 49.2, 56.8, 39.9, 0, 0], + [17.5, -15.2, -8.7, 51, 49.2, 75.3, 73.4, 70.2, 41.6, 0, 0], + [-10.4, 15.2, 11.8, 50.6, 51.6, 50, 50.7, 69.5, 16.7, 0, 0], + [-6, -7.7, -9.4, 43.6, 42.8, 15.5, 18.1, 57.9, 3.5, 0, 0], + [0.2, -3.1, 9, 29.6, 16.8, 51.5, 41.3, 56.4, -7.8, 0, 90], + [-16.2, 38.4, 2.4, 23.7, 26.2, 32.7, 34.6, 53.9, -41.1, 0, 0], + [3.5, -16.1, 15.8, 51, 48.5, 34.9, 33, 55.1, 41.9, 80, -62.2], + [-17.3, 11.2, -9.1, 24.2, 44.5, 44.5, 32.2, 55, -36.5, 18.5, 67.9], + [-0.7, 3.6, 12.2, 42.1, 22.2, 41.7, 22.1, 60.4, -9.1, 0, 0], + [-25.3, -12.4, -13.3, 30.5, 26.8, 49.9, 48.8, 56.2, -35.8, 0, 0], + [-41.1, 20.2, 18.8, 44.6, 53, 74.9, 77.8, 70.8, -40.6, 0, 0], + [-14.6, -12.5, -16.1, 51.4, 50.5, 50.1, 49.4, 69, -20, 0, 0], + [10, 2.7, 8.8, 42.9, 43.3, 16.4, 17.8, 57.9, 2.7, 0, 0], + [-17.8, 10, -6.3, 28.8, 17.3, 51.4, 42.7, 56.6, -9.8, 0, 90], + [-29.6, 7.5, 10.1, 21.5, 23.2, 32, 33.5, 51.2, -37.4, 0, 0], +] + +export const initialExpressions: Expression[] = calibrated.map( + ( + [ + headX, + headY, + headZ, + widthLeft, + widthRight, + heightLeft, + heightRight, + spacing, + latitude, + leftAngle, + rightAngle, + ], + index + ) => ({ + id: `expression-${String(index).padStart(2, '0')}`, + headX, + headY, + headZ, + widthLeft, + widthRight, + heightLeft, + heightRight, + spacing, + positionXLeft: 0, + positionXRight: 0, + positionYLeft: latitude, + positionYRight: latitude, + leftAngle, + rightAngle, + perspective: 1, + eyeMotion: 'none', + bodyMotion: 'none', + }) +) + +export const defaultExpression: Expression = { + id: 'expression-neutral', + headX: 0, + headY: 0, + headZ: 0, + widthLeft: 20, + widthRight: 20, + heightLeft: 50, + heightRight: 50, + spacing: 35, + positionXLeft: 0, + positionXRight: 0, + positionYLeft: -7, + positionYRight: -7, + leftAngle: 0, + rightAngle: 0, + perspective: 1, + eyeMotion: 'none', + bodyMotion: 'none', +} + +export const stateGroups = { + 'Cycle de vie': ['sleeping', 'waking', 'idle', 'listening', 'thinking', 'searching', 'working'], + Réactions: [ + 'excited', + 'surprised', + 'suspicious', + 'angry', + 'drowsy', + 'happy', + 'curious', + 'confused', + 'bored', + 'proud', + 'shy', + 'sad', + 'laughing', + 'scared', + 'playful', + 'celebrate', + ], +} as const + +export const statePools: Record = { + sleeping: [13, 22, 4], + waking: [13], + idle: [0, 8], + listening: [10, 1, 19], + thinking: [8, 16, 14, 17, 5], + searching: [15, 9, 3, 20, 12, 18], + working: [7, 16, 11, 10], + excited: [2, 17, 21, 3, 11], + surprised: [3, 21], + suspicious: [14, 5, 23], + angry: [7, 16], + drowsy: [4, 22, 13], + happy: [2, 11, 17, 19], + curious: [3, 21, 0, 15], + confused: [14, 5, 8], + bored: [4, 22, 0], + proud: [15, 8, 2], + shy: [0, 24, 13], + sad: [4, 13, 22], + laughing: [2, 11, 17], + scared: [3, 21], + playful: [2, 17, 11, 8], + celebrate: [2, 8, 17], +} + +export type StatePlaybackConfig = { + expressionIntervalMs: number + blink: { + initialDelayMs: number + minIntervalMs: number + maxIntervalMs: number + durationMs: number + } +} + +const blinkProfiles = { + natural: { + initialDelayMs: 2600, + minIntervalMs: 3400, + maxIntervalMs: 6200, + durationMs: 280, + }, + calm: { + initialDelayMs: 4800, + minIntervalMs: 6500, + maxIntervalMs: 9500, + durationMs: 420, + }, + attentive: { + initialDelayMs: 3200, + minIntervalMs: 4800, + maxIntervalMs: 7200, + durationMs: 240, + }, + active: { + initialDelayMs: 2100, + minIntervalMs: 2800, + maxIntervalMs: 5000, + durationMs: 260, + }, + reactive: { + initialDelayMs: 1200, + minIntervalMs: 1800, + maxIntervalMs: 3600, + durationMs: 220, + }, +} as const + +const calmStates = new Set(['sleeping', 'drowsy', 'bored', 'sad']) +const attentiveStates = new Set(['listening']) +const reactiveStates = new Set([ + 'waking', + 'excited', + 'surprised', + 'laughing', + 'scared', + 'celebrate', +]) + +export const getStatePlaybackConfig = (name: string): StatePlaybackConfig => { + const blink = + name === 'idle' + ? blinkProfiles.natural + : calmStates.has(name) + ? blinkProfiles.calm + : attentiveStates.has(name) + ? blinkProfiles.attentive + : reactiveStates.has(name) + ? blinkProfiles.reactive + : blinkProfiles.active + return { + expressionIntervalMs: name === 'idle' ? 5200 : calmStates.has(name) ? 3600 : 2300, + blink: { ...blink }, + } +} + +export const stateNotes: Record = { + sleeping: 'Yeux presque fermés, respiration lente et expression de sommeil.', + waking: 'Animation courte de réveil avant retour vers une expression neutre.', + idle: 'Micro-mouvements lents, expressions 00 et 08, clignement rare.', + listening: 'Expressions 10, 01 et 19, regard stable et clignement attentif.', + thinking: 'Regard haut et latéral, expressions asymétriques et changements fréquents.', + searching: 'Balayage rapide et changements très fréquents.', + working: 'Rythme régulier et expressions concentrées.', + excited: 'Grandes expressions et transitions rapides.', + curious: 'Inclinaisons et forte asymétrie.', +} diff --git a/src/proceduralBrowserRuntime.ts b/src/proceduralBrowserRuntime.ts new file mode 100644 index 0000000..2d88c4d --- /dev/null +++ b/src/proceduralBrowserRuntime.ts @@ -0,0 +1,351 @@ +export const proceduralBrowserRuntime = ` +const SVG_NS = 'http://www.w3.org/2000/svg'; +const avatarInstanceId = () => typeof globalThis.crypto?.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : Date.now().toString(36) + '-' + Math.random().toString(36).slice(2); +const clamp01 = value => Math.max(0, Math.min(1, value)); +const easeProgress = (progress, transition) => transition === 'smooth' + ? progress * progress * (3 - 2 * progress) + : transition === 'snappy' + ? 1 - (1 - progress) ** 3 + : 1 - Math.exp(-6 * progress) * Math.cos(8 * progress); +const nearestAngle = (target, current) => { + let resolved = target; + while (resolved - current > 180) resolved -= 360; + while (resolved - current < -180) resolved += 360; + return resolved; +}; +const resolvedTargetExpression = (target, current) => ({ + ...target, + headX: nearestAngle(target.headX, current.headX), + headY: nearestAngle(target.headY, current.headY), + headZ: nearestAngle(target.headZ, current.headZ), + leftAngle: nearestAngle(target.leftAngle, current.leftAngle), + rightAngle: nearestAngle(target.rightAngle, current.rightAngle), +}); +const colorChannels = color => { + const value = color.replace('#', ''); + const hex = value.length === 3 ? value.split('').map(channel => channel + channel).join('') : value; + const numeric = Number.parseInt(hex, 16); + return [(numeric >> 16) & 255, (numeric >> 8) & 255, numeric & 255]; +}; +const interpolateColor = (from, to, progress) => { + const left = colorChannels(from); + const right = colorChannels(to); + const value = left.map((channel, index) => Math.round(channel + (right[index] - channel) * progress)); + return '#' + value.map(channel => channel.toString(16).padStart(2, '0')).join(''); +}; +const resolveColors = expression => ({ + body: expression.bodyColor || DATA.avatar.colors.body, + eyes: expression.eyeColor || DATA.avatar.colors.eyes, +}); +const svgElement = name => document.createElementNS(SVG_NS, name); + +function mountAvatar(target, options = {}) { + const host = typeof target === 'string' ? document.querySelector(target) : target; + if (!host) throw new Error('Avatar target was not found.'); + const animationNames = Object.keys(DATA.animations); + if (!animationNames.length) throw new Error('The avatar export contains no animations.'); + const instanceId = avatarInstanceId(); + const clipId = 'avatar-procedural-clip-' + instanceId; + const svg = svgElement('svg'); + svg.setAttribute('viewBox', '-150 -150 300 300'); + svg.setAttribute('role', 'img'); + svg.setAttribute('aria-label', DATA.avatar.name); + svg.style.width = typeof options.size === 'number' ? options.size + 'px' : options.size || '100%'; + svg.style.height = typeof options.size === 'number' ? options.size + 'px' : options.size || '100%'; + svg.style.display = 'block'; + svg.style.overflow = 'visible'; + const defs = svgElement('defs'); + const clipPath = svgElement('clipPath'); + const clipHead = svgElement('path'); + clipPath.id = clipId; + clipPath.append(clipHead); + defs.append(clipPath); + svg.append(defs); + const motionLayer = svgElement('g'); + const backLayer = svgElement('g'); + const head = svgElement('path'); + const eyesLayer = svgElement('g'); + const leftEye = svgElement('path'); + const rightEye = svgElement('path'); + const frontLayer = svgElement('g'); + eyesLayer.setAttribute('clip-path', 'url(#' + clipId + ')'); + eyesLayer.append(leftEye, rightEye); + motionLayer.append(backLayer, head, eyesLayer, frontLayer); + svg.append(motionLayer); + host.replaceChildren(svg); + + const ensurePaths = (group, paths, fill) => { + while (group.children.length < paths.length) group.append(svgElement('path')); + while (group.children.length > paths.length) group.lastElementChild.remove(); + paths.forEach((path, index) => { + group.children[index].setAttribute('d', path); + group.children[index].setAttribute('fill', fill); + }); + }; + let currentAnimation = options.animation && DATA.animations[options.animation] ? options.animation : animationNames[0]; + const initialStep = DATA.animations[currentAnimation].steps[0]; + const initialExpression = DATA.expressions[initialStep.expressionId]; + let currentPose = AvatarProceduralEngine.poseFromExpression(initialExpression); + let currentColors = resolveColors(initialExpression); + let blinkAmount = 1; + let transitionState = null; + let blinkState = null; + let frameRequest = null; + let stepTimer = null; + let blinkTimer = null; + let blinkDueAt = null; + let stepIndex = 0; + let direction = 1; + let playing = false; + let paused = false; + let pausedRemainingMs = 0; + let pausedTransition = null; + let pausedBlink = null; + let pausedBlinkDelay = 0; + let stepDueAt = null; + let ambientStartedAt = performance.now(); + let ambientSignature = initialExpression.eyeMotion + ':' + initialExpression.bodyMotion; + let lastAmbientFrame = 0; + + const applyMotion = expression => { + const signature = expression.eyeMotion + ':' + expression.bodyMotion; + if (signature !== ambientSignature) { + ambientSignature = signature; + ambientStartedAt = performance.now(); + } + }; + const render = (time = performance.now()) => { + const elapsed = time - ambientStartedAt; + const expression = AvatarProceduralEngine.hasAmbientMotion(currentPose.expression) + ? AvatarProceduralEngine.applyAmbientMotion(currentPose.expression, elapsed) + : currentPose.expression; + const renderedPose = AvatarProceduralEngine.poseFromExpression(expression); + const geometry = AvatarProceduralEngine.renderAvatar(renderedPose, DATA.avatar.surface, blinkAmount, { + includeWire: false, + bodyNodes: DATA.avatar.bodyNodes, + }); + const offset = AvatarProceduralEngine.ambientBodyOffset(currentPose.expression, elapsed); + motionLayer.setAttribute('transform', 'translate(' + offset.x + ' ' + offset.y + ')'); + ensurePaths(backLayer, geometry.backPaths, currentColors.body); + ensurePaths(frontLayer, geometry.frontPaths, currentColors.body); + head.setAttribute('d', geometry.headPath); + head.setAttribute('fill', currentColors.body); + clipHead.setAttribute('d', geometry.headPath); + leftEye.setAttribute('d', geometry.leftPath); + rightEye.setAttribute('d', geometry.rightPath); + leftEye.setAttribute('fill', currentColors.eyes); + rightEye.setAttribute('fill', currentColors.eyes); + leftEye.style.display = geometry.leftVisible ? '' : 'none'; + rightEye.style.display = geometry.rightVisible ? '' : 'none'; + }; + const tick = time => { + frameRequest = null; + if (transitionState) { + const linear = clamp01((time - transitionState.startedAt) / transitionState.durationMs); + const eased = easeProgress(linear, transitionState.transition); + const expression = { ...transitionState.fromPose.expression }; + AvatarProceduralEngine.expressionFields.forEach(field => { + expression[field] = transitionState.fromPose.expression[field] + + (transitionState.toPose.expression[field] - transitionState.fromPose.expression[field]) * eased; + }); + expression.eyeMotion = transitionState.toPose.expression.eyeMotion; + expression.bodyMotion = transitionState.toPose.expression.bodyMotion; + currentPose = AvatarProceduralEngine.poseFromExpression(expression); + currentColors = { + body: interpolateColor(transitionState.fromColors.body, transitionState.toColors.body, clamp01(eased)), + eyes: interpolateColor(transitionState.fromColors.eyes, transitionState.toColors.eyes, clamp01(eased)), + }; + if (linear >= 1) { + currentPose = transitionState.toPose; + currentColors = transitionState.toColors; + transitionState = null; + } + } + if (blinkState) { + const progress = clamp01((time - blinkState.startedAt) / blinkState.durationMs); + if (progress <= 0.42) { + const closeProgress = progress / 0.42; + blinkAmount = 1 - closeProgress * closeProgress; + } else { + const openProgress = (progress - 0.42) / 0.58; + blinkAmount = 1 - (1 - openProgress) ** 2; + } + if (progress >= 1) { + blinkAmount = 1; + blinkState = null; + } + } + const ambientActive = AvatarProceduralEngine.hasAmbientMotion(currentPose.expression); + if (transitionState || blinkState || !ambientActive || time - lastAmbientFrame >= 1000 / 30) { + render(time); + if (ambientActive) lastAmbientFrame = time; + } + if (transitionState || blinkState || ambientActive) frameRequest = requestAnimationFrame(tick); + }; + const requestTick = () => { + if (frameRequest === null) frameRequest = requestAnimationFrame(tick); + }; + const animateTo = (expressionId, durationMs, transition) => { + const target = DATA.expressions[expressionId]; + if (!target) return; + applyMotion(target); + const resolved = resolvedTargetExpression(target, currentPose.expression); + const targetPose = AvatarProceduralEngine.poseFromExpression(resolved); + const targetColors = resolveColors(target); + if (durationMs <= 0) { + transitionState = null; + currentPose = targetPose; + currentColors = targetColors; + render(); + if (AvatarProceduralEngine.hasAmbientMotion(currentPose.expression)) requestTick(); + return; + } + transitionState = { + fromPose: currentPose, + toPose: targetPose, + fromColors: currentColors, + toColors: targetColors, + startedAt: performance.now(), + durationMs, + transition, + expressionId, + }; + requestTick(); + }; + const clearSchedule = () => { + if (stepTimer !== null) clearTimeout(stepTimer); + if (blinkTimer !== null) clearTimeout(blinkTimer); + stepTimer = null; + blinkTimer = null; + blinkDueAt = null; + stepDueAt = null; + }; + const scheduleBlink = (animation, delay) => { + if (!animation.blink.enabled) return; + blinkDueAt = performance.now() + delay; + blinkTimer = setTimeout(() => { + blinkDueAt = null; + blinkState = { startedAt: performance.now(), durationMs: animation.blink.durationMs }; + requestTick(); + const range = animation.blink.maxIntervalMs - animation.blink.minIntervalMs; + scheduleBlink(animation, animation.blink.durationMs + animation.blink.minIntervalMs + Math.random() * range); + }, delay); + }; + const advance = animation => { + const last = animation.steps.length - 1; + const playbackMode = options.loop === true ? 'loop' : options.loop === false ? 'once' : animation.playbackMode; + if (playbackMode === 'once' && stepIndex >= last) { + playing = false; + options.onAnimationEnd?.(currentAnimation); + return; + } + if (playbackMode === 'pingPong' && last > 0) { + if (stepIndex >= last) direction = -1; + else if (stepIndex <= 0) direction = 1; + stepIndex += direction; + } else stepIndex = (stepIndex + 1) % (last + 1); + runStep(animation); + }; + const runStep = animation => { + if (!playing || !animation.steps.length) return; + const step = animation.steps[stepIndex]; + animateTo(step.expressionId, step.transitionMs, step.transition); + const duration = step.transitionMs + step.holdMs; + stepDueAt = performance.now() + duration; + stepTimer = setTimeout(() => advance(animation), duration); + }; + const api = { + element: svg, + get animation() { return currentAnimation; }, + get playing() { return playing; }, + play(animationName) { + animationName = animationName || currentAnimation; + if (!DATA.animations[animationName]) throw new Error('Unknown animation: ' + animationName); + clearSchedule(); + if (animationName === currentAnimation && paused) { + paused = false; + playing = true; + if (pausedTransition) animateTo(pausedTransition.expressionId, pausedTransition.durationMs, pausedTransition.transition); + if (pausedBlink) { + blinkState = { + startedAt: performance.now() - pausedBlink.progress * pausedBlink.durationMs, + durationMs: pausedBlink.durationMs, + }; + requestTick(); + } + stepDueAt = performance.now() + pausedRemainingMs; + stepTimer = setTimeout(() => advance(DATA.animations[currentAnimation]), pausedRemainingMs); + scheduleBlink( + DATA.animations[currentAnimation], + pausedBlinkDelay || DATA.animations[currentAnimation].blink.minIntervalMs + ); + pausedTransition = null; + pausedBlink = null; + pausedBlinkDelay = 0; + return api; + } + currentAnimation = animationName; + stepIndex = 0; + direction = 1; + paused = false; + playing = true; + runStep(DATA.animations[currentAnimation]); + scheduleBlink(DATA.animations[currentAnimation], DATA.animations[currentAnimation].blink.initialDelayMs); + return api; + }, + pause() { + const now = performance.now(); + if (playing && stepDueAt !== null) pausedRemainingMs = Math.max(stepDueAt - now, 0); + pausedBlinkDelay = blinkDueAt === null ? 0 : Math.max(blinkDueAt - now, 0); + if (transitionState) { + const elapsed = now - transitionState.startedAt; + pausedTransition = { + expressionId: transitionState.expressionId, + durationMs: Math.max(transitionState.durationMs - elapsed, 0), + transition: transitionState.transition, + }; + } + if (blinkState) { + pausedBlink = { + progress: clamp01((now - blinkState.startedAt) / blinkState.durationMs), + durationMs: blinkState.durationMs, + }; + } + clearSchedule(); + transitionState = null; + blinkState = null; + paused = true; + playing = false; + render(); + return api; + }, + stop() { + clearSchedule(); + transitionState = null; + blinkState = null; + blinkAmount = 1; + pausedBlink = null; + pausedBlinkDelay = 0; + paused = false; + playing = false; + stepIndex = 0; + direction = 1; + const first = DATA.animations[currentAnimation].steps[0]; + if (first) animateTo(first.expressionId, 0, first.transition); + return api; + }, + destroy() { + clearSchedule(); + if (frameRequest !== null) cancelAnimationFrame(frameRequest); + svg.remove(); + }, + }; + applyMotion(initialExpression); + render(); + if (AvatarProceduralEngine.hasAmbientMotion(initialExpression)) requestTick(); + if (options.autoplay !== false) api.play(currentAnimation); + return api; +} +` diff --git a/src/renderedRotationGizmo.ts b/src/renderedRotationGizmo.ts new file mode 100644 index 0000000..ab5cf32 --- /dev/null +++ b/src/renderedRotationGizmo.ts @@ -0,0 +1,43 @@ +import { motionValue, type MotionValue } from 'motion' + +import { poseFromExpression, rotationRing, type Expression, type Point3 } from './geometry' + +export type RenderedRotationGizmo = { + xPath: MotionValue + yPath: MotionValue + zPath: MotionValue +} + +export const rotationRingPath = (points: Point3[]) => + `M${points[0][0]} ${points[0][1]}${points + .slice(1) + .map(point => `L${point[0]} ${point[1]}`) + .join('')}Z` + +const rotationPaths = (expression: Expression) => { + const pose = poseFromExpression(expression) + return { + x: rotationRingPath(rotationRing(pose, 'x')), + y: rotationRingPath(rotationRing(pose, 'y')), + z: rotationRingPath(rotationRing(pose, 'z')), + } +} + +export const createRenderedRotationGizmo = (expression: Expression): RenderedRotationGizmo => { + const paths = rotationPaths(expression) + return { + xPath: motionValue(paths.x), + yPath: motionValue(paths.y), + zPath: motionValue(paths.z), + } +} + +export const paintRenderedRotationGizmo = ( + gizmo: RenderedRotationGizmo, + expression: Expression +) => { + const paths = rotationPaths(expression) + gizmo.xPath.jump(paths.x) + gizmo.yPath.jump(paths.y) + gizmo.zPath.jump(paths.z) +} diff --git a/src/renderedScene.ts b/src/renderedScene.ts new file mode 100644 index 0000000..952b929 --- /dev/null +++ b/src/renderedScene.ts @@ -0,0 +1,82 @@ +import { motionValue, type MotionValue } from 'motion' + +import type { AvatarColors } from './avatars' +import { MAX_BODY_NODES } from './body' +import type { AvatarGeometry } from './geometry' + +export type RenderedScene = { + headPath: MotionValue + backPaths: MotionValue[] + frontPaths: MotionValue[] + backNodeIds: { current: (string | null)[] } + frontNodeIds: { current: (string | null)[] } + leftPath: MotionValue + rightPath: MotionValue + leftOpacity: MotionValue + rightOpacity: MotionValue + offsetX: MotionValue + offsetY: MotionValue + wirePaths: MotionValue[] +} + +export type RenderedColors = { + body: MotionValue + eyes: MotionValue +} + +const bodyPathSlots = MAX_BODY_NODES + 2 + +export const createRenderedScene = (geometry: AvatarGeometry): RenderedScene => ({ + headPath: motionValue(geometry.headPath), + backPaths: Array.from({ length: bodyPathSlots }, (_, index) => + motionValue(geometry.backPaths[index] ?? '') + ), + frontPaths: Array.from({ length: bodyPathSlots }, (_, index) => + motionValue(geometry.frontPaths[index] ?? '') + ), + backNodeIds: { current: geometry.backNodeIds }, + frontNodeIds: { current: geometry.frontNodeIds }, + leftPath: motionValue(geometry.leftPath), + rightPath: motionValue(geometry.rightPath), + leftOpacity: motionValue(geometry.leftVisible ? 1 : 0), + rightOpacity: motionValue(geometry.rightVisible ? 1 : 0), + offsetX: motionValue(0), + offsetY: motionValue(0), + wirePaths: geometry.wirePaths.map(path => motionValue(path)), +}) + +export const createRenderedColors = (colors: AvatarColors): RenderedColors => ({ + body: motionValue(colors.body), + eyes: motionValue(colors.eyes), +}) + +export const paintRenderedColors = (rendered: RenderedColors, colors: AvatarColors) => { + rendered.body.set(colors.body) + rendered.eyes.set(colors.eyes) +} + +export const paintRenderedOffset = (scene: RenderedScene, offset: { x: number; y: number }) => { + scene.offsetX.set(offset.x) + scene.offsetY.set(offset.y) +} + +export const paintRenderedScene = (scene: RenderedScene, geometry: AvatarGeometry) => { + scene.headPath.set(geometry.headPath) + scene.backNodeIds.current = geometry.backNodeIds + scene.frontNodeIds.current = geometry.frontNodeIds + scene.backPaths.forEach((path, index) => path.set(geometry.backPaths[index] ?? '')) + scene.frontPaths.forEach((path, index) => path.set(geometry.frontPaths[index] ?? '')) + scene.leftPath.set(geometry.leftPath) + scene.rightPath.set(geometry.rightPath) + scene.leftOpacity.set(geometry.leftVisible ? 1 : 0) + scene.rightOpacity.set(geometry.rightVisible ? 1 : 0) + scene.wirePaths.forEach((path, index) => path.set(geometry.wirePaths[index] ?? '')) +} + +export const findBodyNodePath = (scene: RenderedScene, selectedBodyNodeId: 'primary' | string) => { + if (selectedBodyNodeId === 'primary') return scene.headPath + const backIndex = scene.backNodeIds.current.indexOf(selectedBodyNodeId) + if (backIndex >= 0) return scene.backPaths[backIndex] + const frontIndex = scene.frontNodeIds.current.indexOf(selectedBodyNodeId) + return frontIndex >= 0 ? scene.frontPaths[frontIndex] : null +} diff --git a/src/sequences.ts b/src/sequences.ts new file mode 100644 index 0000000..d02b75a --- /dev/null +++ b/src/sequences.ts @@ -0,0 +1,288 @@ +import { + getStatePlaybackConfig, + initialExpressions, + stateGroups, + stateNotes, + statePools, +} from './presets' +import type { Expression } from './geometry' + +export type SequencePlaybackMode = 'loop' | 'once' | 'pingPong' +export type SequenceTransition = 'spring' | 'smooth' | 'snappy' + +export type SequenceStep = { + id: string + expressionId: string + holdMs: number + transitionMs: number + transition: SequenceTransition +} + +export type BlinkSettings = { + enabled: boolean + initialDelayMs: number + minIntervalMs: number + maxIntervalMs: number + durationMs: number +} + +export type AvatarSequence = { + id: string + name: string + group: string + description: string + builtIn: boolean + playbackMode: SequencePlaybackMode + steps: SequenceStep[] + blink: BlinkSettings +} + +export type SequenceCursor = { + index: number + direction: 1 | -1 + complete: boolean +} + +const playbackModes: SequencePlaybackMode[] = ['loop', 'once', 'pingPong'] +const transitions: SequenceTransition[] = ['spring', 'smooth', 'snappy'] +const builtInSequenceIds = new Set(Object.values(stateGroups).flat()) + +const finite = (value: unknown, fallback: number, min: number, max: number) => + typeof value === 'number' && Number.isFinite(value) + ? Math.min(Math.max(value, min), max) + : fallback + +const createId = (prefix: string) => + `${prefix}-${typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`}` + +const cloneSequence = (sequence: AvatarSequence): AvatarSequence => ({ + ...sequence, + steps: sequence.steps.map(step => ({ ...step })), + blink: { ...sequence.blink }, +}) + +export const createInitialSequences = (): AvatarSequence[] => + Object.entries(stateGroups).flatMap(([group, stateIds]) => + stateIds.map(id => { + const playback = getStatePlaybackConfig(id) + return { + id, + name: id, + group, + description: + stateNotes[id] ?? 'Cette animation enchaîne un pool de presets et des clignements.', + builtIn: true, + playbackMode: 'loop', + steps: (statePools[id] ?? [0]).map((expressionIndex, index) => ({ + id: `${id}-step-${index}`, + expressionId: initialExpressions[expressionIndex]?.id ?? initialExpressions[0].id, + holdMs: playback.expressionIntervalMs, + transitionMs: 500, + transition: 'smooth', + })), + blink: { enabled: true, ...playback.blink }, + } + }) + ) + +const parseStep = (value: unknown, fallback: SequenceStep): SequenceStep => { + const candidate = value as Partial | null + return { + id: typeof candidate?.id === 'string' ? candidate.id : createId('step'), + expressionId: + typeof candidate?.expressionId === 'string' && candidate.expressionId + ? candidate.expressionId + : fallback.expressionId, + holdMs: finite(candidate?.holdMs, fallback.holdMs, 100, 60000), + transitionMs: finite(candidate?.transitionMs, fallback.transitionMs, 0, 5000), + transition: transitions.includes(candidate?.transition as SequenceTransition) + ? (candidate?.transition as SequenceTransition) + : fallback.transition, + } +} + +const parseSequence = (value: unknown, fallback: AvatarSequence): AvatarSequence => { + const candidate = value as Partial | null + const fallbackStep = fallback.steps[0] ?? { + id: 'fallback-step', + expressionId: initialExpressions[0].id, + holdMs: 3000, + transitionMs: 500, + transition: 'smooth' as const, + } + const steps = Array.isArray(candidate?.steps) + ? candidate.steps.map(step => parseStep(step, fallbackStep)) + : fallback.steps.map(step => ({ ...step })) + const storedBlink = candidate?.blink as Partial | undefined + const minIntervalMs = finite(storedBlink?.minIntervalMs, fallback.blink.minIntervalMs, 100, 60000) + return { + id: typeof candidate?.id === 'string' ? candidate.id : fallback.id, + name: + typeof candidate?.name === 'string' && candidate.name.trim() ? candidate.name : fallback.name, + group: + typeof candidate?.group === 'string' && candidate.group.trim() + ? candidate.group + : fallback.group, + description: + typeof candidate?.description === 'string' ? candidate.description : fallback.description, + builtIn: + typeof candidate?.builtIn === 'boolean' + ? candidate.builtIn + : typeof candidate?.id === 'string' && builtInSequenceIds.has(candidate.id), + playbackMode: playbackModes.includes(candidate?.playbackMode as SequencePlaybackMode) + ? (candidate?.playbackMode as SequencePlaybackMode) + : fallback.playbackMode, + steps, + blink: { + enabled: + typeof storedBlink?.enabled === 'boolean' ? storedBlink.enabled : fallback.blink.enabled, + initialDelayMs: finite(storedBlink?.initialDelayMs, fallback.blink.initialDelayMs, 0, 60000), + minIntervalMs, + maxIntervalMs: Math.max( + minIntervalMs, + finite(storedBlink?.maxIntervalMs, fallback.blink.maxIntervalMs, 100, 60000) + ), + durationMs: finite(storedBlink?.durationMs, fallback.blink.durationMs, 40, 3000), + }, + } +} + +export const parseSequences = (value: unknown): AvatarSequence[] => { + const defaults = createInitialSequences() + if (!Array.isArray(value)) return defaults + if (!value.length) return [] + const fallback = defaults.find(sequence => sequence.id === 'idle') ?? defaults[0] + const seen = new Set() + return value + .map(sequence => parseSequence(sequence, fallback)) + .filter(sequence => { + if (seen.has(sequence.id)) return false + seen.add(sequence.id) + return true + }) +} + +export const normalizeSequencesForExpressions = ( + sequences: AvatarSequence[], + expressions: Expression[] +) => { + const availableIds = new Set(expressions.map(expression => expression.id)) + const fallbackId = expressions[0]?.id ?? initialExpressions[0].id + return sequences.map(sequence => ({ + ...sequence, + steps: (sequence.steps.length ? sequence.steps : [createSequenceStep(fallbackId)]).map( + step => ({ + ...step, + expressionId: availableIds.has(step.expressionId) ? step.expressionId : fallbackId, + }) + ), + })) +} + +export const findExpressionIndex = (expressions: Expression[], expressionId: string) => + expressions.findIndex(expression => expression.id === expressionId) + +export const readSequenceClock = () => performance.now() + +export const groupSequences = (sequences: AvatarSequence[]) => { + const groups: { name: string; sequences: AvatarSequence[] }[] = [] + const indexes = new Map() + sequences.forEach(sequence => { + const existingIndex = indexes.get(sequence.group) + if (existingIndex !== undefined) { + groups[existingIndex].sequences.push(sequence) + return + } + indexes.set(sequence.group, groups.length) + groups.push({ name: sequence.group, sequences: [sequence] }) + }) + return groups +} + +export const createSequence = (expressionId = initialExpressions[0].id): AvatarSequence => ({ + id: createId('sequence'), + name: 'Untitled animation', + group: 'Custom', + description: '', + builtIn: false, + playbackMode: 'loop', + steps: [ + { + id: createId('step'), + expressionId, + holdMs: 3000, + transitionMs: 500, + transition: 'smooth', + }, + ], + blink: { + enabled: true, + initialDelayMs: 2600, + minIntervalMs: 3400, + maxIntervalMs: 6200, + durationMs: 280, + }, +}) + +export const duplicateSequence = (source: AvatarSequence): AvatarSequence => ({ + ...cloneSequence(source), + id: createId('sequence'), + name: `${source.name} copy`, + builtIn: false, + steps: source.steps.map(step => ({ ...step, id: createId('step') })), +}) + +export const createSequenceStep = (expressionId: string): SequenceStep => ({ + id: createId('step'), + expressionId, + holdMs: 3000, + transitionMs: 500, + transition: 'smooth', +}) + +export const advanceSequenceCursor = ( + sequence: AvatarSequence, + index: number, + direction: 1 | -1 +): SequenceCursor => { + const lastIndex = sequence.steps.length - 1 + if (lastIndex < 0) return { index: 0, direction: 1, complete: true } + if (sequence.playbackMode === 'once') { + if (index >= lastIndex) return { index: lastIndex, direction: 1, complete: true } + return { index: index + 1, direction: 1, complete: false } + } + if (sequence.playbackMode === 'pingPong' && lastIndex > 0) { + if (direction === 1 && index >= lastIndex) + return { index: lastIndex - 1, direction: -1, complete: false } + if (direction === -1 && index <= 0) return { index: 1, direction: 1, complete: false } + return { index: index + direction, direction, complete: false } + } + return { index: index >= lastIndex ? 0 : index + 1, direction: 1, complete: false } +} + +export const remapSequencesAfterExpressionDelete = ( + sequences: AvatarSequence[], + deletedExpressionId: string, + fallbackExpressionId: string +) => + sequences.map(sequence => { + const steps = sequence.steps.filter(step => step.expressionId !== deletedExpressionId) + return { + ...sequence, + steps: steps.length ? steps : [createSequenceStep(fallbackExpressionId)], + } + }) + +export const getSequenceSpring = ( + transition: SequenceTransition, + durationMs: number, + baseSpeed: number +) => { + const durationFactor = Math.min(Math.max(500 / Math.max(durationMs, 100), 0.35), 3) + const styleFactor = transition === 'smooth' ? 0.72 : transition === 'snappy' ? 1.45 : 1 + const speed = Math.max(baseSpeed * durationFactor * styleFactor, 0.5) + return { + stiffness: 70 + speed * 24, + damping: (17 + speed * 1.7) * (transition === 'smooth' ? 1.18 : 1), + } +} diff --git a/src/snapshotExporter.ts b/src/snapshotExporter.ts new file mode 100644 index 0000000..7a373d7 --- /dev/null +++ b/src/snapshotExporter.ts @@ -0,0 +1,84 @@ +import type { AvatarColors } from './avatars' +import type { RenderedScene } from './renderedScene' + +export type SnapshotBackground = 'transparent' | 'solid' | 'linear' | 'radial' + +export type SnapshotOptions = { + background: SnapshotBackground + colorFrom: string + colorTo: string + size: number +} + +const escapeXml = (value: string) => + value.replace(/[&<>"]/g, character => { + const entities: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + } + return entities[character] + }) + +const path = (value: string, fill: string, opacity = 1) => + value ? `` : '' + +const backgroundMarkup = (options: SnapshotOptions) => { + if (options.background === 'transparent') return '' + const fill = + options.background === 'solid' + ? options.colorFrom + : options.background === 'linear' + ? 'url(#snapshot-linear)' + : 'url(#snapshot-radial)' + return `` +} + +const gradientMarkup = (options: SnapshotOptions) => { + if (options.background === 'linear') { + return `` + } + if (options.background === 'radial') { + return `` + } + return '' +} + +export const serializeAvatarSnapshot = ( + name: string, + scene: RenderedScene, + colors: AvatarColors, + options: SnapshotOptions +) => { + const headPath = scene.headPath.get() + const backPaths = scene.backPaths.map(item => item.get()).filter(Boolean) + const frontPaths = scene.frontPaths.map(item => item.get()).filter(Boolean) + const offsetX = scene.offsetX.get() + const offsetY = scene.offsetY.get() + const body = [ + ...backPaths.map(value => path(value, colors.body)), + path(headPath, colors.body), + `${path(scene.leftPath.get(), colors.eyes, scene.leftOpacity.get())}${path(scene.rightPath.get(), colors.eyes, scene.rightOpacity.get())}`, + ...frontPaths.map(value => path(value, colors.body)), + ].join('') + + return ` + + ${gradientMarkup(options)} + ${backgroundMarkup(options)} + ${body} +` +} + +export const snapshotFileName = (name: string, extension: 'svg' | 'png' = 'svg') => { + const slug = + name + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') || 'avatar' + return `${slug}-snapshot.${extension}` +} diff --git a/src/standaloneEngine.entry.ts b/src/standaloneEngine.entry.ts new file mode 100644 index 0000000..3c09894 --- /dev/null +++ b/src/standaloneEngine.entry.ts @@ -0,0 +1,2 @@ +export { expressionFields, poseFromExpression, renderAvatar } from './geometry' +export { ambientBodyOffset, applyAmbientMotion, hasAmbientMotion } from './ambientMotion' diff --git a/src/standaloneEngine.generated.ts b/src/standaloneEngine.generated.ts new file mode 100644 index 0000000..e7f91ec --- /dev/null +++ b/src/standaloneEngine.generated.ts @@ -0,0 +1,2 @@ +// Generated by scripts/generate-standalone-engine.mjs. Do not edit. +export const standaloneEngineSource = "var AvatarProceduralEngine=(function(e){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var t=(e,t)=>Math.sign(e)*Math.abs(e)**t,n=(e,n,r,i,a,o,s)=>{let c=t(Math.cos(n),o);return[r/2*c*t(Math.sin(e),s),i/2*t(Math.sin(n),o),a/2*c*t(Math.cos(e),s)]},r=(e,t,n)=>{let r=e.width/2,i=e.depth/2,a=Math.min(r,e.height/2),o=Math.max(0,(e.height-a*2)/2),s=o*2+Math.PI*a,c=(n+Math.PI/2)/Math.PI*s,l=r,u=0;if(cMath.max(0,Math.min(2,e??0)),a=e=>1+i(e.roundness)/2,o=.04,s=e=>e.roundness<=0?1/0:2/(o+i(e.roundness)/2*.96),c=(e,t,n,r)=>{let i=Math.cos(n)*Math.sin(t),a=Math.sin(n),o=Math.cos(n)*Math.cos(t),s=Number.isFinite(r)?(Math.abs(i)**r+Math.abs(a)**r+Math.abs(o)**r)**(1/r)||1:Math.max(Math.abs(i),Math.abs(a),Math.abs(o))||1;return[e.width/2*(i/s),e.height/2*(a/s),e.depth/2*(o/s)]},l=(e,t,n)=>c(e,t,n,a(e)),u=(e,t,n)=>c(e,t,n,s(e)),d=.24,f=.2,p=.22,m=e=>i(e.morphRoundness)/2,h=(e,t,n)=>{let r=m(e),i=Math.max(0,Math.min(1,t)),a=Math.sin(i*Math.PI),o=(1-Math.cos(i*Math.PI))/2;return{radiusScale:n.radiusScale+(a-n.radiusScale)*r,verticalProgress:n.verticalProgress+(o-n.verticalProgress)*r}},g=(e,t,n,r,i)=>{let a=1-i;return a**3*e+3*a*a*i*t+3*a*i*i*n+i**3*r},_=e=>({tipFraction:(e.tipRoundness??0)*d,baseFraction:(e.baseRoundness??0)*f}),v=(e,t)=>{let n=Math.max(0,Math.min(1,t)),r=e.roundness*p;if(r<=0)return{radiusScale:1,verticalProgress:(Math.sin((n-.5)*Math.PI)+1)/2};if(n1-r){let e=(n-(1-r))/r*(Math.PI/2);return{radiusScale:1-r+r*Math.cos(e),verticalProgress:1-r/2+r*Math.sin(e)/2}}let i=(n-r)/(1-r*2);return{radiusScale:1,verticalProgress:r/2+i*(1-r)}},y=(e,t)=>h(e,t,v(e,t)),b=(e,t,n)=>{let r=Math.max(0,Math.min(1,t)),i=0,a=1;for(let t=0;t<14;t+=1){let t=(i+a)/2;n(e,t).verticalProgress{let n=Math.max(0,Math.min(1,t)),{tipFraction:r,baseFraction:i}=_(e);if(i>0&&n0&&n>1-r){let e=(n-(1-r))/r;return{radiusScale:g(r,r/2,r/4,0,e),verticalProgress:g(1-r,1-r/2,1,1,e)}}return{radiusScale:1-n,verticalProgress:n}},S=(e,t)=>h(e,t,x(e,t)),C=e=>{let t=e.height*.36,n=e.height-t;return{coneApexY:-e.height/2,coneBaseY:-e.height/2+t,bodyHeight:n,bodyCenterY:e.height/2-n/2,bodyWidth:e.width*.54,bodyDepth:e.depth*.62}},w=(e,t,i)=>{let{width:a,height:o,depth:s}=e;switch(e.type){case`sphere`:case`mickey`:return n(t,i,a,o,s,1,1);case`cube`:return u(e,t,i);case`cylinder`:{let n=y(e,(i+Math.PI/2)/Math.PI);return[a/2*n.radiusScale*Math.sin(t),-o/2+o*n.verticalProgress,s/2*n.radiusScale*Math.cos(t)]}case`cursor`:{let n=C(e),r=(i+Math.PI/2)/Math.PI,a=v({...e,width:n.bodyWidth,height:n.bodyHeight,depth:n.bodyDepth},r);return[n.bodyWidth/2*a.radiusScale*Math.sin(t),n.bodyCenterY-n.bodyHeight/2+n.bodyHeight*a.verticalProgress,n.bodyDepth/2*a.radiusScale*Math.cos(t)]}case`diamond`:return l(e,t,i);case`capsule`:return r(e,t,i);case`cone`:{let n=S(e,(i+Math.PI/2)/Math.PI);return[a/2*n.radiusScale*Math.sin(t),o/2-o*n.verticalProgress,s/2*n.radiusScale*Math.cos(t)]}}},ee=(e,t)=>[e[0]-t[0],e[1]-t[1],e[2]-t[2]],T=([e,t,n])=>{let r=Math.hypot(e,t,n)||1;return[e/r,t/r,n/r]},te=(e,t,n)=>{let r=e.type===`cone`?-1:1;return T([r*(t[1]*n[2]-t[2]*n[1]),r*(t[2]*n[0]-t[0]*n[2]),r*(t[0]*n[1]-t[1]*n[0])])},ne=(e,t,n)=>{let r=5e-4;if(e.type===`cone`&&n>=Math.PI/2-r)return[0,-1,0];let i=w(e,t-r,n),a=w(e,t+r,n),o=w(e,t,Math.max(-Math.PI/2,n-r)),s=w(e,t,Math.min(Math.PI/2,n+r));return te(e,ee(a,i),ee(s,o))},E=(e,t)=>Math.sign(e)*Math.abs(e)**t,re=(e,t,n)=>{let r=e.width/2||1,i=e.height/2||1,a=e.depth/2||1;return T([E(t[0]/r,n-1)/r,E(t[1]/i,n-1)/i,E(t[2]/a,n-1)/a])},ie=(e,t)=>re(e,t,a(e)),ae=(e,t)=>{let n=s(e);if(Number.isFinite(n))return re(e,t,n);let r=[t[0]/(e.width/2||1),t[1]/(e.height/2||1),t[2]/(e.depth/2||1)],i=r.reduce((e,t,n)=>Math.abs(t)>Math.abs(r[e])?n:e,0);return[i===0?Math.sign(r[0]):0,i===1?Math.sign(r[1]):0,i===2?Math.sign(r[2]):0]},oe=(e,t,n,r,i)=>{let a=e.width/2||1,o=e.height/2||1,s=e.depth/2||1;if(!Number.isFinite(r)){let r=[Math.max(-a,Math.min(a,t)),Math.max(-o,Math.min(o,n)),s];return{point:r,normal:i(e,r)}}let c=Math.max(-1,Math.min(1,n/o)),l=Math.max(0,1-Math.abs(c)**r)**(1/r),u=Math.max(-a*l,Math.min(a*l,t)),d=u/a,f=Math.max(0,1-Math.abs(d)**r-Math.abs(c)**r)**(1/r),p=[u,c*o,s*f];return{point:p,normal:i(e,p)}},se=(e,t,n,r,i,a=0)=>{let o=t-a,s=Math.max(0,1-(e/(n||1))**2-(o/(r||1))**2),c=i*Math.sqrt(s);return{point:[e,t,c],normal:T([e/(n*n||1),o/(r*r||1),c/(i*i||1)])}},D=(e,t,n,r,i)=>{let a=e.width/2||1,o=e.depth/2||1,s=Math.max(0,Math.min(1,.5+i*(n/e.height))),c=b(e,s,r),l=a*c,u=o*c,d=Math.max(-l,Math.min(l,t)),f=l>0?Math.max(0,1-(d/l)**2):0,p=u*Math.sqrt(f),m=1e-4,h=Math.max(0,s-m),g=Math.min(1,s+m),_=b(e,h,r),v=(b(e,g,r)-_)/(g-h||1),y=Math.max(Math.sqrt(f),1e-4),x=-(o/a)*d/(l*y||1),S=i*o*v/(e.height*y||1);return{point:[d,n,p],normal:T([-x,-S,1])}},ce=(e,t,n)=>{let r=e.width/2||1,i=e.height/2||1,o=e.depth/2||1;switch(e.type){case`sphere`:case`mickey`:return se(t,n,r,i,o);case`cube`:return oe(e,t,n,s(e),ae);case`capsule`:{let e=Math.min(r,i),a=Math.max(0,i-e);return se(t,n,r,e,o,n<-a?-a:n>a?a:n)}case`cylinder`:return D(e,t,n,y,1);case`cursor`:{let r=C(e),i=D({...e,width:r.bodyWidth,height:r.bodyHeight,depth:r.bodyDepth},t,n-r.bodyCenterY,v,1);return{point:[i.point[0],i.point[1]+r.bodyCenterY,i.point[2]],normal:i.normal}}case`cone`:return D(e,t,n,S,-1);case`diamond`:return oe(e,t,n,a(e),ie)}},le=(e,t,n)=>{let r=w(e,t,n);if(e.type===`sphere`||e.type===`mickey`){let t=e.width/2||1,n=e.height/2||1,i=e.depth/2||1;return{point:r,normal:T([r[0]/(t*t),r[1]/(n*n),r[2]/(i*i)])}}return e.type===`cylinder`&&e.roundness<=0&&(e.morphRoundness??0)<=0?{point:r,normal:T([Math.sin(t)/(e.width/2||1),0,Math.cos(t)/(e.depth/2||1)])}:e.type===`diamond`?{point:r,normal:ie(e,r)}:e.type===`cube`?{point:r,normal:ae(e,r)}:{point:r,normal:ne(e,t,n)}},O=620,ue=14,de=[`headX`,`headY`,`headZ`,`widthLeft`,`widthRight`,`heightLeft`,`heightRight`,`spacing`,`positionXLeft`,`positionXRight`,`positionYLeft`,`positionYRight`,`leftAngle`,`rightAngle`,`perspective`],k=e=>e*Math.PI/180,fe=([e,t,n,r])=>{let i=Math.hypot(e,t,n,r)||1;return[e/i,t/i,n/i,r/i]},pe=([e,t,n,r],[i,a,o,s])=>fe([e*i-t*a-n*o-r*s,e*a+t*i+n*s-r*o,e*o-t*s+n*i+r*a,e*s+t*o-n*a+r*i]),A=([e,t,n],r)=>{let i=r/2,a=Math.sin(i);return fe([Math.cos(i),e*a,t*a,n*a])},j=(e,t,n)=>{let r=A([1,0,0],e),i=A([0,1,0],t);return pe(pe(A([0,0,1],n),r),i)},M=([e,t,n,r],[i,a,o])=>{let s=2*(n*o-r*a),c=2*(r*i-t*o),l=2*(t*a-n*i);return[i+e*s+(n*l-r*c),a+e*c+(r*s-t*l),o+e*l+(t*c-n*s)]},me=(e,t)=>{let n=e/2,r=t/2,i=Math.min(r,n),a=[],o=(e,t)=>{let n=Math.max(2,Math.ceil(Math.hypot(t[0]-e[0],t[1]-e[1])/1.5));for(let r=0;r{for(let r=0;r{let n=O-e[2]*t,r=Math.abs(n)<1e-4?O/1e-4:O/n;return[e[0]*r,e[1]*r,e[2]]},P=(e,t=!0)=>e.length?`M${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}${e.slice(1).map(e=>`L${e[0].toFixed(2)} ${e[1].toFixed(2)}`).join(``)}${t?`Z`:``}`:``,he=e=>({expression:e,orientation:j(k(e.headX),k(e.headY),k(e.headZ))}),ge=24,_e=25,ve=73,F=144,ye=33,be=73,I=new Map,L=new Map,R=new Map,z=e=>[e.type,e.width,e.height,e.depth,e.roundness,e.morphRoundness,e.tipRoundness,e.baseRoundness].map(e=>typeof e==`number`?e.toFixed(4):e).join(`:`),B=(e,t,n)=>(e.size>=ge&&e.delete(e.keys().next().value),e.set(t,n),n),V=(e,t,n)=>le(e,t,n),xe=(e,t)=>({point:N(M(e.orientation,t.point),e.expression.perspective),normal:M(e.orientation,t.normal)}),Se=(e,t)=>{let n=e/120,r=t/120;return[120*Math.cos(r)*Math.sin(n),120*Math.sin(r)]},Ce=(e,t,n,r)=>{let[i,a]=Se(n,r);return xe(e,ce(t,i,a))},we=(e,t,n,r)=>{let i=e.expression,a=n<0?`Left`:`Right`,o=i[`width${a}`],s=5+(i[`height${a}`]-5)*r,c=n*i.spacing/2+i[`positionX${a}`],l=i[`positionY${a}`],u=k(n<0?i.leftAngle:i.rightAngle);return me(o,s).map(([n,r])=>{let i=n*Math.cos(u)-r*Math.sin(u),a=n*Math.sin(u)+r*Math.cos(u);return Ce(e,t,c+i,l+a)})},Te=e=>{let t=[],n=[];return e.forEach(({point:e,normal:r})=>{r[2]>0?n.push(e):n.length&&(t.push(n),n=[])}),n.length&&t.push(n),t.filter(e=>e.length>1).map(e=>P(e,!1)).join(``)},Ee=(e,t)=>{let n=z(t),r=R.get(n);if(!r){let e=[-60,-30,0,30,60].map(e=>Array.from({length:73},(n,r)=>V(t,k(-180+r*5),k(e)))),i=Array.from({length:12},(e,t)=>-150+t*30).map(e=>Array.from({length:37},(n,r)=>V(t,k(e),k(-90+r*5))));r=B(R,n,[...e,...i])}return r.map(t=>Te(t.map(t=>xe(e,t))))},H=e=>{let t=[...e].sort((e,t)=>e[0]-t[0]||e[1]-t[1]),n=(e,t,n)=>(t[0]-e[0])*(n[1]-e[1])-(t[1]-e[1])*(n[0]-e[0]),r=e=>{let t=[];return e.forEach(e=>{for(;t.length>=2&&n(t.at(-2),t.at(-1),e)<=0;)t.pop();t.push(e)}),t};return[...r(t).slice(0,-1),...r(t.reverse()).slice(0,-1)]},U=e=>{if(e.length<3)return P(e);let t=t=>e[(t+e.length)%e.length];return`M${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}${e.map((e,n)=>{let r=t(n-1),i=t(n+1),a=t(n+2),o=[e[0]+(i[0]-r[0])/6,e[1]+(i[1]-r[1])/6,e[2]],s=[i[0]-(a[0]-e[0])/6,i[1]-(a[1]-e[1])/6,i[2]];return`C${o[0].toFixed(2)} ${o[1].toFixed(2)} ${s[0].toFixed(2)} ${s[1].toFixed(2)} ${i[0].toFixed(2)} ${i[1].toFixed(2)}`}).join(``)}Z`},W=(e,t=7)=>e.flatMap((n,r)=>{let i=e[(r+1)%e.length],a=Math.max(1,Math.ceil(Math.hypot(i[0]-n[0],i[1]-n[1])/t));return Array.from({length:a},(e,t)=>{let r=t/a;return[n[0]+(i[0]-n[0])*r,n[1]+(i[1]-n[1])*r,n[2]+(i[2]-n[2])*r]})}),De=e=>e.length?e.length===1?`${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}`:e.slice(0,-1).map((t,n)=>{let r=e[Math.max(0,n-1)],i=e[n+1],a=e[Math.min(e.length-1,n+2)],o=t[0]+(i[0]-r[0])/6,s=t[1]+(i[1]-r[1])/6,c=i[0]-(a[0]-t[0])/6,l=i[1]-(a[1]-t[1])/6;return`C${o.toFixed(2)} ${s.toFixed(2)} ${c.toFixed(2)} ${l.toFixed(2)} ${i[0].toFixed(2)} ${i[1].toFixed(2)}`}).join(``):``,G=(e,t)=>N(M(e.orientation,t),e.expression.perspective),K=(e,t,n)=>Array.from({length:145},(r,i)=>{let a=i/F*Math.PI*2;return[e/2*Math.sin(a),n,t/2*Math.cos(a)]}),q=(e,t)=>{let n=z(t),r=I.get(n);return r||(r=Array.from({length:ye},(e,n)=>{let r=-Math.PI/2+n/32*Math.PI;return Array.from({length:be},(e,n)=>w(t,-Math.PI+n/72*Math.PI*2,r))}).flat(),B(I,n,r)),U(W(H(r.map(t=>G(e,t)))))},Oe=(e,t)=>{if(t.roundness>0||(t.morphRoundness??0)>0)return q(e,t);let n=t.height/2;return U(W(H([...K(t.width,t.depth,-n),...K(t.width,t.depth,n)].map(t=>G(e,t)))))},ke=(e,t)=>{let n=C(t),r=n.bodyHeight/2;return U(W(H([...K(n.bodyWidth,n.bodyDepth,n.bodyCenterY-r),...K(n.bodyWidth,n.bodyDepth,n.bodyCenterY+r)].map(t=>G(e,t)))))},Ae=(e,t)=>{let n=C(t),r=G(e,[0,n.coneApexY,0]);return U(W(H([...K(t.width,t.depth,n.coneBaseY).map(t=>G(e,t)),r])))},je=(e,t)=>{if((t.morphRoundness??0)>0||(t.tipRoundness??0)>0||(t.baseRoundness??0)>0)return q(e,t);let n=G(e,[0,-t.height/2,0]),r=H([...K(t.width,t.depth,t.height/2).map(t=>G(e,t)),n]),i=r.findIndex(e=>Math.hypot(e[0]-n[0],e[1]-n[1])<.01);if(i<0)return U(r);let a=[...r.slice(i),...r.slice(0,i)].slice(1);return a.length<2?P(r):`M${n[0].toFixed(2)} ${n[1].toFixed(2)}L${a[0][0].toFixed(2)} ${a[0][1].toFixed(2)}${De(a)}L${n[0].toFixed(2)} ${n[1].toFixed(2)}Z`},Me=(e,t)=>{if(t.roundness>0)return q(e,t);let n=t.width/2,r=t.height/2,i=t.depth/2;return P(H([-1,1].flatMap(e=>[-1,1].flatMap(t=>[-1,1].map(a=>[e*n,t*r,a*i]))).map(t=>G(e,t))))},Ne=(e,t)=>{if(t.roundness>0)return q(e,t);let n=t.width/2,r=t.height/2,i=t.depth/2;return P(H([[-n,0,0],[n,0,0],[0,-r,0],[0,r,0],[0,0,-i],[0,0,i]].map(t=>G(e,t))))},Pe=(e,t,n,r,i)=>{let a=n+i,o=Math.hypot(n-i,r*2),s=(a+o)/2,c=(a-o)/2;return s<=0||c<=0?null:{centerX:e,centerY:t,majorRadius:Math.sqrt(s),minorRadius:Math.sqrt(c),rotation:Math.atan2(r*2,n-i)/2}},J=({centerX:e,centerY:t,majorRadius:n,minorRadius:r,rotation:i})=>{let a=i*180/Math.PI,o=Math.cos(i)*n,s=Math.sin(i)*n,c=e+o,l=t+s,u=e-o,d=t-s;return`M${c.toFixed(2)} ${l.toFixed(2)}A${n.toFixed(2)} ${r.toFixed(2)} ${a.toFixed(2)} 0 1 ${u.toFixed(2)} ${d.toFixed(2)}A${n.toFixed(2)} ${r.toFixed(2)} ${a.toFixed(2)} 0 1 ${c.toFixed(2)} ${l.toFixed(2)}Z`},Y=(e,t,n=[0,0,0])=>{let r=[M(e.orientation,[1,0,0]),M(e.orientation,[0,1,0]),M(e.orientation,[0,0,1])],i=M(e.orientation,n);if(Math.abs(e.expression.perspective)<1e-4){let e=r.reduce((e,n,r)=>e+n[0]*n[0]*t[r]*t[r],0),n=r.reduce((e,n,r)=>e+n[0]*n[1]*t[r]*t[r],0),a=r.reduce((e,n,r)=>e+n[1]*n[1]*t[r]*t[r],0);return Pe(i[0],i[1],e,n,a)}let a=t.map(e=>1/(e*e)),o=Array.from({length:3},(e,t)=>Array.from({length:3},(e,n)=>r.reduce((e,r,i)=>e+r[t]*a[i]*r[n],0))),s=O/e.expression.perspective,c=[-i[0],-i[1],s-i[2]],l=[o[0][0]*c[0]+o[0][1]*c[1]+o[0][2]*c[2],o[1][0]*c[0]+o[1][1]*c[1]+o[1][2]*c[2],o[2][0]*c[0]+o[2][1]*c[1]+o[2][2]*c[2]],u=c[0]*l[0]+c[1]*l[1]+c[2]*l[2]-1,d=[l[0],l[1],-s*l[2]],f=[[o[0][0],o[0][1],-s*o[0][2]],[o[1][0],o[1][1],-s*o[1][2]],[-s*o[2][0],-s*o[2][1],s*s*o[2][2]]],p=Array.from({length:3},(e,t)=>Array.from({length:3},(e,n)=>d[t]*d[n]-u*f[t][n])),m=p[0][0]*p[1][1]-p[0][1]*p[0][1];if(Math.abs(m)<1e-12)return null;let h=-(p[1][1]*p[0][2]-p[0][1]*p[1][2])/m,g=(p[0][1]*p[0][2]-p[0][0]*p[1][2])/m,_=-(p[2][2]+p[0][2]*h+p[1][2]*g);if(Math.abs(_)<1e-12)return null;let v=p[0][0]/_,y=p[0][1]/_,b=p[1][1]/_,x=v*b-y*y;return x<=0?null:Pe(h,g,b/x,-y/x,v/x)},Fe=(e,t)=>{let n=Y(e,[t.width/2,t.height/2,t.depth/2]),r=t.width===t.height&&t.height===t.depth;if(n&&r){let e=(n.majorRadius+n.minorRadius)/2;return J({centerX:0,centerY:0,majorRadius:e,minorRadius:e,rotation:0})}return n?J(n):null},Ie=(e,t)=>{if(t.type!==`mickey`)return[];let n=Math.min(t.width,t.height)*.23,r=Math.min(n,t.depth*.29),i=t.width*.37,a=-t.height*.39,o=-t.depth*.12,s=[n,n,r];return[-1,1].map(t=>Y(e,s,[t*i,a,o])).filter(e=>e!==null).map(J)},Le=(e,t)=>t.type===`mickey`?Ie(e,t):t.type===`cursor`?[Ae(e,t)]:[],X=e=>Array.from({length:F},(t,n)=>{let r=n/F*Math.PI*2,i=Math.cos(r)*e.majorRadius,a=Math.sin(r)*e.minorRadius;return[e.centerX+i*Math.cos(e.rotation)-a*Math.sin(e.rotation),e.centerY+i*Math.sin(e.rotation)+a*Math.cos(e.rotation),0]}),Re=e=>{if(e.length<3)return P(e);let t=e.map((t,n)=>{let r=e[(n+1)%e.length];return Math.hypot(r[0]-t[0],r[1]-t[1])}),n=[...t].sort((e,t)=>e-t),r=n[Math.floor(n.length/2)]||1,i=Math.max(8,r*3.5),a=t.map(e=>e>i);return`M${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}${e.map((t,n)=>{let r=(n+1)%e.length,i=e[r];if(a[n])return`L${i[0].toFixed(2)} ${i[1].toFixed(2)}`;let o=a[(n-1+e.length)%e.length]?t:e[(n-1+e.length)%e.length],s=a[r]?i:e[(n+2)%e.length],c=t[0]+(i[0]-o[0])/6,l=t[1]+(i[1]-o[1])/6,u=i[0]-(s[0]-t[0])/6,d=i[1]-(s[1]-t[1])/6;return`C${c.toFixed(2)} ${l.toFixed(2)} ${u.toFixed(2)} ${d.toFixed(2)} ${i[0].toFixed(2)} ${i[1].toFixed(2)}`}).join(``)}Z`},ze=(e,t)=>{let n=t.width/2,r=Math.min(n,t.height/2),i=t.depth/2,a=Math.max(0,(t.height-r*2)/2),o=[n,r,i],s=Y(e,o,[0,a,0]),c=Y(e,o,[0,-a,0]);return!s||!c?null:Re(H([...X(s),...X(c)]))},Be=(e,t)=>{if(t.type===`sphere`||t.type===`mickey`){let n=Fe(e,t);if(n)return n}if(t.type===`capsule`){let n=ze(e,t);if(n)return n}if(t.type===`cylinder`)return Oe(e,t);if(t.type===`cursor`)return ke(e,t);if(t.type===`cone`)return je(e,t);if(t.type===`cube`)return Me(e,t);if(t.type===`diamond`)return Ne(e,t);let n=z(t),r=I.get(n);return r||(r=Array.from({length:_e},(e,n)=>{let r=-Math.PI/2+n/24*Math.PI;return Array.from({length:ve},(e,n)=>w(t,-Math.PI+n/72*Math.PI*2,r))}).flat(),B(I,n,r)),P(H(r.map(t=>N(M(e.orientation,t),e.expression.perspective))))},Ve=(e,t)=>{let n=z(t.surface),r=L.get(n);r||(r=Array.from({length:17},(e,n)=>{let r=-Math.PI/2+n/16*Math.PI;return Array.from({length:49},(e,n)=>{let i=-Math.PI+n/48*Math.PI*2;return w(t.surface,i,r)})}).flat(),B(L,n,r));let i=j(k(t.rotation[0]),k(t.rotation[1]),k(t.rotation[2])),a=H(r.map(n=>{let r=M(i,n),a=[r[0]+t.position[0],r[1]+t.position[1],r[2]+t.position[2]];return N(M(e.orientation,a),e.expression.perspective)}));return(t.surface.type===`cube`||t.surface.type===`diamond`)&&t.surface.roundness<=0?P(a):U(W(a))},He=.1,Ue=(e,t)=>{let n=j(k(t.rotation[0]),k(t.rotation[1]),k(t.rotation[2])),r=[[1,0,0],[0,1,0],[0,0,1]].map(t=>M(e.orientation,M(n,t))[2]);return Math.hypot(r[0]*(t.surface.width/2),r[1]*(t.surface.height/2),r[2]*(t.surface.depth/2))},We=(e,t)=>{let n=t.map(t=>{let n=M(e.orientation,t.position)[2];return{id:t.id,path:Ve(e,t),depth:n,front:n>Ue(e,t)*He}}).sort((e,t)=>e.depth-t.depth);return{backPaths:n.filter(e=>!e.front).map(e=>e.path),frontPaths:n.filter(e=>e.front).map(e=>e.path),backNodeIds:n.filter(e=>!e.front).map(e=>e.id),frontNodeIds:n.filter(e=>e.front).map(e=>e.id)}},Ge=(e,t,n=1,r={})=>{let i=we(e,t,-1,n),a=we(e,t,1,n),o=i.map(e=>e.point),s=a.map(e=>e.point),c=We(e,r.bodyNodes??[]),l=Le(e,t);return{backPaths:[...l,...c.backPaths],frontPaths:c.frontPaths,backNodeIds:[...l.map(()=>null),...c.backNodeIds],frontNodeIds:c.frontNodeIds,headPath:Be(e,t),leftPath:P(o),rightPath:P(s),leftVisible:i.reduce((e,t)=>e+t.normal[2],0)>0,rightVisible:a.reduce((e,t)=>e+t.normal[2],0)>0,wirePaths:r.includeWire===!1?[]:Ee(e,t)}},Ke=e=>e*e*(3-2*e),Z=e=>{let t=Math.sin(e*127.1+311.7)*43758.5453;return(t-Math.floor(t))*2-1},qe=e=>e.headX*.71+e.headY*1.13+e.headZ*1.37,Q=(e,t,n,r)=>{let i=e/r,a=Math.floor(i),o=Ke(i-a),s=Z(a*3+t+n);return s+(Z((a+1)*3+t+n)-s)*o},$=(e,t,n)=>{let r=1100,i=e+Math.sin(e/1700+n)*280,a=Math.floor(i/r),o=(i%r+r)%r/r,s=Ke(Math.min(o/.09,1)),c=Z((a-1)*2+t+n),l=Z(a*2+t+n),u=Math.min(e/240,1);return(c+(l-c)*s)*u};return e.ambientBodyOffset=(e,t)=>{let n=qe(e);if(e.bodyMotion===`slowDrift`)return{x:Q(t,3,n,2900)*1.45,y:Q(t,4,n,3700)*1.1};if(e.bodyMotion===`shake`){let e=t/1e3;return{x:(Math.sin(e*31)+Math.sin(e*53)*.45)*1.35,y:(Math.sin(e*37)+Math.sin(e*61)*.4)*1.1}}return{x:0,y:0}},e.applyAmbientMotion=(e,t)=>{let n={...e},r=qe(e);if(e.eyeMotion===`microSaccades`){let e=$(t,0,r)*1.5,i=$(t,1,r)*.9;n.positionXLeft+=e,n.positionXRight+=e,n.positionYLeft+=i,n.positionYRight+=i}else if(e.eyeMotion===`shake`){let e=t/1e3,r=(Math.sin(e*47)+Math.sin(e*71)*.45)*1.2,i=(Math.sin(e*59)+Math.sin(e*83)*.4)*.8;n.positionXLeft+=r,n.positionXRight+=r,n.positionYLeft+=i,n.positionYRight+=i}if(e.bodyMotion===`slowDrift`)n.headX+=Q(t,0,r,2600)*.8,n.headY+=Q(t,1,r,3300)*1.15,n.headZ+=Q(t,2,r,4100)*.45;else if(e.bodyMotion===`shake`){let e=t/1e3;n.headX+=(Math.sin(e*31)+Math.sin(e*53)*.45)*1.15,n.headY+=(Math.sin(e*37)+Math.sin(e*61)*.4)*1.35,n.headZ+=Math.sin(e*43)*.7}return n},e.expressionFields=de,e.hasAmbientMotion=e=>e.eyeMotion!==`none`||e.bodyMotion!==`none`,e.poseFromExpression=he,e.renderAvatar=Ge,e})({});" diff --git a/src/storedZip.ts b/src/storedZip.ts new file mode 100644 index 0000000..fd75d5c --- /dev/null +++ b/src/storedZip.ts @@ -0,0 +1,61 @@ +type StoredZipFile = { name: string; content: string } + +const crc32 = (bytes: Uint8Array) => { + let crc = 0xffffffff + for (const byte of bytes) { + crc ^= byte + for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)) + } + return (crc ^ 0xffffffff) >>> 0 +} + +export const createStoredZip = (files: StoredZipFile[]) => { + const encoder = new TextEncoder() + const chunks: Uint8Array[] = [] + const centralEntries: Uint8Array[] = [] + let offset = 0 + const header = (size: number) => { + const bytes = new Uint8Array(size) + return { bytes, view: new DataView(bytes.buffer) } + } + + for (const file of files) { + const name = encoder.encode(file.name) + const content = encoder.encode(file.content) + const checksum = crc32(content) + const local = header(30 + name.length) + local.view.setUint32(0, 0x04034b50, true) + local.view.setUint16(4, 20, true) + local.view.setUint32(14, checksum, true) + local.view.setUint32(18, content.length, true) + local.view.setUint32(22, content.length, true) + local.view.setUint16(26, name.length, true) + local.bytes.set(name, 30) + chunks.push(local.bytes, content) + + const central = header(46 + name.length) + central.view.setUint32(0, 0x02014b50, true) + central.view.setUint16(4, 20, true) + central.view.setUint16(6, 20, true) + central.view.setUint32(16, checksum, true) + central.view.setUint32(20, content.length, true) + central.view.setUint32(24, content.length, true) + central.view.setUint16(28, name.length, true) + central.view.setUint32(42, offset, true) + central.bytes.set(name, 46) + centralEntries.push(central.bytes) + offset += local.bytes.length + content.length + } + + const centralSize = centralEntries.reduce((size, entry) => size + entry.length, 0) + const end = header(22) + end.view.setUint32(0, 0x06054b50, true) + end.view.setUint16(8, files.length, true) + end.view.setUint16(10, files.length, true) + end.view.setUint32(12, centralSize, true) + end.view.setUint32(16, offset, true) + const parts = [...chunks, ...centralEntries, end.bytes].map( + bytes => Uint8Array.from(bytes).buffer as ArrayBuffer + ) + return new Blob(parts, { type: 'application/zip' }) +} diff --git a/src/studioDocument.ts b/src/studioDocument.ts new file mode 100644 index 0000000..4ce9572 --- /dev/null +++ b/src/studioDocument.ts @@ -0,0 +1,140 @@ +import { + parseAvatarLibrary, + parseExpressions, + type AvatarBehaviorLibrary, + type AvatarLibrary, +} from './avatars' +import type { Expression } from './geometry' +import { normalizeSequencesForExpressions, parseSequences, type AvatarSequence } from './sequences' +import defaultStudioDocument from './defaultStudioDocument.json' + +export type StatePlaybackSelection = { stateId: string | null; playing: boolean } + +export type StudioDocument = { + version: 2 + library: AvatarLibrary + expressions: Expression[] + sequences: AvatarSequence[] + playback: StatePlaybackSelection +} + +export type StudioDocumentPatch = Partial> + +const DOCUMENT_STORAGE_KEY = 'bible-strong-avatar-studio-v2' + +const defaultPlayback: StatePlaybackSelection = { stateId: 'idle', playing: true } + +const parsePlayback = ( + value: unknown, + fallback: StatePlaybackSelection = defaultPlayback +): StatePlaybackSelection => { + const candidate = value as Partial | null + if (!candidate || (typeof candidate.stateId !== 'string' && candidate.stateId !== null)) { + return { ...fallback } + } + return { stateId: candidate.stateId, playing: candidate.playing === true } +} + +export const parseStudioDocument = (value: unknown, fallback: StudioDocument): StudioDocument => { + const candidate = value as Partial | null + if (!candidate || candidate.version !== 2) return fallback + const expressions = + Array.isArray(candidate.expressions) && candidate.expressions.length + ? parseExpressions(candidate.expressions) + : fallback.expressions + const sequences = Array.isArray(candidate.sequences) + ? normalizeSequencesForExpressions(parseSequences(candidate.sequences), expressions) + : fallback.sequences + const baseBehavior: AvatarBehaviorLibrary = { expressions, sequences } + const library = parseAvatarLibrary(candidate.library, fallback.library, baseBehavior) + return { + version: 2, + library, + expressions, + sequences, + playback: parsePlayback(candidate.playback, fallback.playback), + } +} + +export const serializeStudioDocument = (document: StudioDocument) => + JSON.stringify(document, null, 2) + +export const parseImportedStudioDocument = ( + source: string, + fallback: StudioDocument +): StudioDocument => { + let value: unknown + try { + value = JSON.parse(source) + } catch { + throw new Error('Invalid Avatar Studio project') + } + const candidate = value as Partial | null + if (!candidate || candidate.version !== 2) { + throw new Error('Unsupported Avatar Studio project') + } + if ( + !candidate.library || + !Array.isArray(candidate.library.avatars) || + !candidate.library.avatars.length || + !Array.isArray(candidate.expressions) || + !candidate.expressions.length || + !Array.isArray(candidate.sequences) + ) { + throw new Error('Invalid Avatar Studio project') + } + return parseStudioDocument(candidate, fallback) +} + +const createBundledStudioDocument = () => { + const snapshot = JSON.parse(JSON.stringify(defaultStudioDocument)) as StudioDocument + return parseStudioDocument(snapshot, snapshot) +} + +export const loadStudioDocument = ( + storage: Pick = window.localStorage +): StudioDocument => { + const fallback = createBundledStudioDocument() + try { + return parseStudioDocument( + JSON.parse(storage.getItem(DOCUMENT_STORAGE_KEY) ?? 'null'), + fallback + ) + } catch { + return fallback + } +} + +export const persistStudioDocument = (document: StudioDocument) => { + try { + window.localStorage.setItem(DOCUMENT_STORAGE_KEY, JSON.stringify(document)) + return true + } catch { + // The in-memory document remains authoritative when storage is unavailable. + return false + } +} + +export const createStudioDocumentStore = ( + initial: StudioDocument, + persist: (document: StudioDocument) => void = persistStudioDocument +) => { + let current = initial + return { + update: (patch: StudioDocumentPatch) => { + const expressions = patch.expressions ?? current.expressions + current = { + ...current, + ...patch, + version: 2, + expressions, + sequences: normalizeSequencesForExpressions( + patch.sequences ?? current.sequences, + expressions + ), + } + persist(current) + return current + }, + } +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..06cc349 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,2177 @@ +@import 'tailwindcss'; + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); +} + +:root { + --background: #ffffff; + --foreground: #17191d; + --card: #ffffff; + --card-foreground: #17191d; + --popover: #ffffff; + --popover-foreground: #17191d; + --primary: #17191d; + --primary-foreground: #ffffff; + --secondary: #edf1ff; + --secondary-foreground: #4168d5; + --muted: #f0f1f4; + --muted-foreground: #767d88; + --destructive: #c83737; + --border: #dcdfe6; + --input: #d9dce3; + --ring: #5b7fe5; + color: #17191d; + background: #eef0f4; + font-family: + Inter, + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-synthesis: none; +} + +* { + box-sizing: border-box; +} +body { + margin: 0; + min-width: 320px; + min-height: 100vh; +} +button, +input { + font: inherit; +} +.studio { + display: grid; + min-height: 100vh; + grid-template-columns: minmax(460px, 1fr) minmax(440px, 520px); +} + +.stage-column { + position: sticky; + top: 0; + display: flex; + height: 100vh; + flex-direction: column; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 28px; + background: #101316; + color: white; +} + +.stage-column::before { + position: absolute; + width: 70vw; + height: 70vw; + border-radius: 50%; + background: radial-gradient(circle, rgb(91 127 229 / 18%), transparent 68%); + content: ''; + pointer-events: none; +} +.photo-flash { + position: absolute; + z-index: 20; + inset: 0; + background: white; + pointer-events: none; +} +.photo-capture-bar { + position: absolute; + z-index: 8; + bottom: 58px; + left: 50%; + display: flex; + align-items: center; + gap: 8px; + transform: translateX(-50%); +} +.photo-capture-button { + min-width: 190px; + height: 44px; + padding-inline: 15px 10px; + border: 1px solid rgb(255 255 255 / 14%); + border-radius: 13px; + background: rgb(246 248 252 / 96%); + box-shadow: + 0 12px 32px rgb(0 0 0 / 28%), + inset 0 1px 0 rgb(255 255 255 / 78%); + color: #17191d; + transition-property: transform, background-color, box-shadow; + transition-duration: 160ms; + transition-timing-function: cubic-bezier(0.2, 0, 0, 1); +} +.photo-capture-button:hover { + background: white; + box-shadow: + 0 15px 36px rgb(0 0 0 / 34%), + inset 0 1px 0 white; +} +.photo-capture-button:active { + transform: scale(0.96); +} +.photo-capture-button > svg { + width: 17px; + height: 17px; +} +.photo-format-badge { + margin-left: auto; + padding: 3px 6px; + border-radius: 6px; + background: #e9edfa; + color: #4168d5; + font-size: 9px; + font-weight: 850; + letter-spacing: 0.08em; +} +.photo-help-button { + width: 40px; + height: 40px; + border: 1px solid rgb(255 255 255 / 13%); + border-radius: 12px; + background: rgb(26 30 34 / 88%); + box-shadow: 0 10px 28px rgb(0 0 0 / 24%); + color: #f6f7f8; + backdrop-filter: blur(12px); +} +.photo-help-button:hover { + background: rgb(40 45 51 / 96%); + color: white; +} +.photo-help-tooltip { + width: min(280px, calc(100vw - 32px)); + padding: 9px 11px; + line-height: 1.45; + text-wrap: pretty; +} + +.brand { + position: absolute; + top: 28px; + left: 32px; + display: flex; + align-items: center; + gap: 9px; + font-size: 13px; + font-weight: 760; + letter-spacing: -0.01em; +} + +.brand em { + color: #77808c; + font-style: normal; + font-weight: 600; +} +.brand-mark { + width: 20px; + height: 20px; + border-radius: 50%; + background: #5b7fe5; + box-shadow: 0 0 0 3px rgb(91 127 229 / 15%); +} +.language-picker { + position: absolute; + z-index: 5; + top: 24px; + right: 28px; + display: flex; + align-items: center; + gap: 7px; + min-height: 34px; + padding: 4px 8px 4px 10px; + border: 1px solid rgb(255 255 255 / 12%); + border-radius: 10px; + background: rgb(26 30 34 / 86%); + box-shadow: 0 8px 24px rgb(0 0 0 / 18%); + backdrop-filter: blur(12px); +} +.language-picker > span { + font-size: 15px; + line-height: 1; +} +.language-picker [data-slot='select-trigger'] { + width: auto; + height: 24px; + min-width: 82px; + padding: 0 4px; + border: 0; + background: transparent; + box-shadow: none; + color: #f6f7f8; + font-size: 10px; + font-weight: 750; +} +.language-picker [data-slot='select-trigger']:focus-visible { + border-radius: 5px; + box-shadow: 0 0 0 2px #7895ee; +} + +.avatar-wrap { + position: relative; + z-index: 1; + width: min(72vh, 72vw, 680px); + aspect-ratio: 1; +} +.stage-playback-status { + position: absolute; + z-index: 6; + top: 3%; + left: 3%; + padding: 9px 11px; + border: 1px solid rgb(255 255 255 / 12%); + border-radius: 12px; + background: rgb(26 30 34 / 86%); + box-shadow: 0 8px 24px rgb(0 0 0 / 18%); + backdrop-filter: blur(12px); + pointer-events: none; +} +.stage-playback-status .playback-identity { + min-width: 104px; + padding: 0; +} +.stage-playback-status .playback-identity strong { + color: #f6f7f8; +} +.stage-playback-status .playback-identity small { + color: #8d96a2; +} + +.avatar { + display: block; + width: 100%; + height: 100%; + overflow: visible; + cursor: grab; + touch-action: none; +} +.avatar:active { + cursor: grabbing; +} + +.export-avatar-summary { + display: flex; + align-items: center; + gap: 14px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--muted); +} + +.export-avatar-summary > svg { + width: 62px; + height: 62px; + flex: none; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--card); +} + +.export-avatar-summary > div { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; +} + +.export-avatar-summary small, +.export-format-grid small { + color: var(--muted-foreground); + font-size: 11px; + font-weight: 500; +} + +.export-avatar-summary strong { + overflow: hidden; + font-size: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.export-format-grid { + display: grid; + min-width: 0; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.export-format-grid > button { + justify-content: flex-start; + min-width: 0; + height: auto; + min-height: 68px; + padding: 10px; + text-align: left; +} + +.export-format-grid > button[aria-pressed='true'] { + border-color: #6f8ff0; + background: #edf1ff; + box-shadow: 0 0 0 2px rgb(91 127 229 / 14%); + color: #3159cb; +} + +.export-format-grid > button > svg { + width: 16px; + height: 16px; +} + +.export-format-grid > button > span { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; +} +.export-format-grid strong, +.export-format-grid small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.export-format-grid strong { + font-size: 11px; +} +.export-format-grid small { + font-size: 9px; +} + +.export-animation-header { + align-items: center; +} + +.export-animation-header > button { + color: #4168d5; + font-size: 11px; +} + +.export-animation-grid { + align-items: flex-start; +} + +.export-download { + width: 100%; + min-height: 44px; + box-shadow: 0 12px 30px rgb(23 25 29 / 18%); +} +.export-panel { + width: 100%; + min-width: 0; +} +.export-accordion-item { + min-width: 0; + overflow: hidden; + border-color: #dcdfe6; + box-shadow: 0 4px 14px rgb(24 28 35 / 5%); +} +.export-accordion-trigger > span { + display: grid; + min-width: 0; + gap: 5px; +} +.export-accordion-trigger strong { + color: #343b49; + font-size: 12px; + font-weight: 850; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.export-accordion-trigger small { + color: #858c99; + font-size: 10px; + font-weight: 500; + line-height: 1.45; + white-space: normal; +} +.export-accordion-trigger[aria-expanded='true'] > svg { + transform: rotate(180deg); +} +.export-accordion-content { + min-width: 0; + transition: height 200ms ease; +} +.export-accordion-inner { + display: grid; + min-width: 0; + gap: 10px; +} +.export-accordion-inner > * { + min-width: 0; +} +.export-placeholder { + margin: 0; + padding: 12px; + color: var(--muted-foreground); + border: 1px dashed var(--border); + border-radius: 12px; + background: var(--muted); + font-size: 12px; + line-height: 1.5; +} +.project-transfer-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.project-transfer-actions > button { + min-height: 42px; + font-size: 11px; +} +.project-import-input { + display: none; +} +.project-transfer-error { + margin: 10px 0 0; + color: var(--destructive); + font-size: 12px; + line-height: 1.45; +} +.snapshot-preview-card { + display: flex; + align-items: center; + gap: 14px; +} +.snapshot-preview-card > div:last-child { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; +} +.snapshot-preview-card small, +.snapshot-preview-card span, +.snapshot-background-field small { + color: var(--muted-foreground); + font-size: 10px; +} +.snapshot-preview-card strong { + overflow: hidden; + font-size: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} +.snapshot-preview { + width: 112px; + height: 112px; + flex: none; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 12px; + background: white; +} +.snapshot-preview.is-transparent { + background-color: white; + background-image: + linear-gradient(45deg, #e7e9ee 25%, transparent 25%), + linear-gradient(-45deg, #e7e9ee 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #e7e9ee 75%), + linear-gradient(-45deg, transparent 75%, #e7e9ee 75%); + background-position: + 0 0, + 0 6px, + 6px -6px, + -6px 0; + background-size: 12px 12px; +} +.snapshot-preview svg { + display: block; + width: 100%; + height: 100%; +} +.snapshot-background-field { + align-items: center; +} +.snapshot-background-field > [data-slot='select-trigger'] { + width: 190px; +} +.snapshot-background-field > div:first-child { + display: grid; + gap: 3px; +} +.snapshot-colors { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 14px; +} +.snapshot-settings-separator { + margin-block: 14px; +} +.avatar-head, +.preview-head { + fill: var(--avatar-body-color, #5b7fe5); +} +.avatar-eye, +.preview-eye { + fill: var(--avatar-eye-color, #111316); +} +.avatar-head, +.avatar-eye { + transition: fill 420ms cubic-bezier(0.22, 1, 0.36, 1); +} +.avatar-eye { + cursor: pointer; +} +.cyan-outline { + stroke: #51ddff; + stroke-width: 2.6; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; +} +.selection-outline { + fill: none; + stroke: #51ddff; + stroke-width: 2.4; + stroke-linecap: round; + stroke-linejoin: round; + pointer-events: none; +} +.body-selection-outline { + stroke-width: 3; + filter: drop-shadow(0 0 3px rgb(81 221 255 / 52%)); +} +.body-node-gizmo { + isolation: isolate; +} +.body-gizmo-ring, +.body-gizmo-axis { + fill: none; + vector-effect: non-scaling-stroke; + touch-action: none; + transition: + opacity 120ms ease, + stroke-width 120ms ease, + filter 120ms ease; +} +.body-gizmo-hitbox { + fill: none; + stroke: transparent; + stroke-width: 14; + vector-effect: non-scaling-stroke; + cursor: grab; + touch-action: none; +} +.body-gizmo-ring { + stroke-width: 1.5; + opacity: 0.42; + cursor: grab; +} +.body-gizmo-axis { + stroke-width: 2.4; + cursor: move; +} +.body-gizmo-ring-hitbox:hover + .body-gizmo-ring, +.body-gizmo-ring.is-active, +.body-gizmo-axis-hitbox:hover + .body-gizmo-axis, +.body-gizmo-axis.is-active { + stroke-width: 4; + opacity: 1; + filter: drop-shadow(0 0 2px currentColor); +} +.body-gizmo-handle { + stroke-width: 1.5; + vector-effect: non-scaling-stroke; + cursor: move; + touch-action: none; +} +.body-gizmo-handle.gizmo-x { + fill: #46d477; +} +.body-gizmo-handle.gizmo-y { + fill: #ef5f5f; +} +.body-gizmo-handle.gizmo-z { + fill: #557bea; +} +.body-gizmo-origin { + fill: #f8fafc; + stroke: #11161d; + stroke-width: 1.5; + vector-effect: non-scaling-stroke; + transition: + r 120ms ease, + stroke-width 120ms ease, + filter 120ms ease; +} +.body-gizmo-plane-hitbox { + fill: transparent; + cursor: move; + touch-action: none; +} +.body-gizmo-plane-hitbox:hover + .body-gizmo-origin, +.body-gizmo-origin.is-active { + stroke: #51ddff; + stroke-width: 3; + filter: drop-shadow(0 0 3px rgb(81 221 255 / 72%)); +} +.body-gizmo-reset { + cursor: pointer; + outline: none; +} +.body-gizmo-reset circle { + fill: rgb(17 22 29 / 92%); + stroke: #8da7ef; + stroke-width: 1.3; + vector-effect: non-scaling-stroke; +} +.body-gizmo-reset text { + fill: #f8fafc; + font-size: 8px; + font-weight: 800; + pointer-events: none; + text-anchor: middle; +} +.body-gizmo-reset:hover circle, +.body-gizmo-reset:focus-visible circle { + fill: #2c4d9d; + stroke: #c6d5ff; + stroke-width: 2.5; +} +.body-gizmo-label { + fill: #f8fafc; + font-size: 7px; + font-weight: 900; + paint-order: stroke; + pointer-events: none; + stroke: #11161d; + stroke-width: 2px; + text-anchor: middle; +} +.editor-guide { + fill: none; + stroke: rgb(81 221 255 / 58%); + stroke-width: 1.15; + stroke-dasharray: 3 3; + pointer-events: none; +} +.editor-control { + cursor: grab; + touch-action: none; +} +.editor-control:active { + cursor: grabbing; +} +.editor-handle { + fill: #111316; + stroke: #51ddff; + stroke-width: 2; +} +.editor-label { + fill: #fff; + font-size: 6px; + font-weight: 900; + text-anchor: middle; + pointer-events: none; + user-select: none; +} +.wire { + fill: none; + stroke: rgb(255 255 255 / 28%); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.gizmo-cluster { + position: absolute; + top: 2%; + right: 2%; + display: flex; + flex-direction: row-reverse; + align-items: flex-start; + gap: 5px; +} +.gizmo { + width: 94px; + height: 94px; + overflow: visible; +} +.gizmo-reset { + width: 26px; + height: 26px; + border-color: rgb(255 255 255 / 12%); + background: rgb(24 29 36 / 82%); + color: #dbe4f3; + backdrop-filter: blur(8px); +} +.gizmo-reset:hover { + background: rgb(46 54 65 / 92%); + color: white; +} +.gizmo-orbit { + fill: none; + stroke-width: 2; + vector-effect: non-scaling-stroke; + cursor: ew-resize; + transition: + stroke-width 140ms ease, + filter 140ms ease; + touch-action: none; +} +.gizmo-orbit:hover, +.gizmo-orbit:active { + stroke-width: 5; + filter: brightness(1.25); +} +.gizmo-camera { + stroke: #f0ce45; +} +.gizmo-x { + stroke: #46d477; +} +.gizmo-y { + stroke: #ef5f5f; +} +.gizmo-z { + stroke: #557bea; +} +.axis-key { + position: absolute; + right: 4%; + bottom: 3%; + display: flex; + align-items: center; + gap: 6px; + color: #77808c; + font-size: 10px; + font-weight: 800; +} +.axis-key i { + display: block; + width: 6px; + height: 6px; + margin-left: 4px; + border-radius: 50%; +} +.axis-key .x { + background: #46d477; +} +.axis-key .y { + background: #ef5f5f; +} +.axis-key .z { + background: #557bea; +} + +.stage-help { + position: absolute; + bottom: 28px; + width: min(520px, 82%); + margin: 0; + color: #77808c; + font-size: 12px; + line-height: 1.5; + text-align: center; +} + +.inspector { + height: 100vh; + overflow-x: hidden; + overflow-y: auto; + scrollbar-gutter: stable; + padding: 28px 26px 48px; + border-left: 1px solid #d9dce3; + background: #f5f6f8; +} +.inspector.expression-workspace-active, +.inspector.sequence-workspace-active, +.inspector.body-workspace { + padding-top: 0; +} +.inspector.state-player-active { + padding-bottom: 128px; +} +@keyframes studio-workspace-in { + from { + opacity: 0; + transform: translateX(-10px); + } +} +@keyframes body-workspace-in { + from { + opacity: 0; + transform: translateX(18px); + } +} +.studio-workspace { + animation: studio-workspace-in 220ms cubic-bezier(0.22, 1, 0.36, 1); +} +.body-workspace { + animation: body-workspace-in 220ms cubic-bezier(0.22, 1, 0.36, 1); +} +@media (prefers-reduced-motion: reduce) { + .studio-workspace, + .body-workspace { + animation: none; + } +} +.workspace-page { + display: flex; + min-height: calc(100vh - 76px); + flex-direction: column; + padding-bottom: 74px; +} +.workspace-header { + position: sticky; + z-index: 8; + top: 0; + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 18px; + padding: 31px 2px 16px; + border-bottom: 1px solid #d9dce3; + background: linear-gradient(to bottom, #f5f6f8 78%, rgb(245 246 248 / 88%)); + backdrop-filter: blur(10px); +} +.workspace-header > [data-slot='button']:first-child { + flex: 0 0 auto; + margin-top: 4px; + border: 1px solid #dfe2e8; + border-radius: 50%; + background: white; + box-shadow: 0 4px 14px rgb(24 28 35 / 5%); +} +.workspace-heading { + min-width: 0; + flex: 1; +} +.workspace-heading h1 { + font-size: 25px; +} +.workspace-heading > p:last-child { + max-width: 360px; + margin: 6px 0 0; + color: #818792; + font-size: 11px; + line-height: 1.45; +} +.workspace-header-actions { + display: flex; + flex: 0 0 auto; + gap: 8px; +} +.workspace-scroll { + display: grid; + gap: 12px; +} +.workspace-footer { + position: fixed; + z-index: 8; + right: 0; + bottom: 0; + display: flex; + width: min(520px, 100vw); + box-sizing: border-box; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 0; + padding: 20px 28px 18px; + border-top: 1px solid #d9dce3; + background: linear-gradient(to bottom, rgb(245 246 248 / 86%), #f5f6f8 30%); + backdrop-filter: blur(10px); +} +.workspace-footer > [data-slot='button']:last-child, +.workspace-footer .dialog-actions-main [data-slot='button'] { + min-width: 120px; +} +.workspace-footer-secondary { + display: flex; + align-items: center; + gap: 8px; +} +.inspector-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 22px; +} +.eyebrow { + margin: 0 0 6px; + color: #7185c4; + font-size: 10px; + font-weight: 850; + letter-spacing: 0.11em; + text-transform: uppercase; +} +h1 { + margin: 0; + font-size: 30px; + letter-spacing: -0.045em; +} +h2, +h3, +p { + margin-top: 0; +} +.state-player { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px; + border: 1px solid #dde1e9; + border-radius: 12px; + background: white; + box-shadow: 0 5px 18px rgb(24 28 35 / 5%); +} +.playback-identity { + display: inline-flex; + align-items: center; + gap: 8px; + color: #33834b; +} +.state-player .playback-identity { + min-width: 102px; + padding: 2px 7px; +} +.playback-identity.is-paused { + color: #8a6a24; +} +.playback-identity.is-stopped { + color: #7f8692; +} +.playback-identity i { + width: 6px; + height: 6px; + flex: 0 0 auto; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 0 4px color-mix(in srgb, currentColor 10%, transparent); +} +.playback-identity > span { + display: grid; + gap: 1px; +} +.playback-identity small { + color: #9297a1; + font-size: 8px; + font-weight: 750; + letter-spacing: 0.06em; + line-height: 1; + text-transform: uppercase; +} +.playback-identity strong { + max-width: 92px; + overflow: hidden; + color: #343943; + font-size: 10px; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} +.state-player [data-slot='button'] { + width: 48px; + height: 48px; +} +.state-player svg { + width: 32px; + height: 32px; +} +.state-playback-footer { + position: fixed; + z-index: 9; + right: 0; + bottom: 0; + width: min(520px, 100vw); + box-sizing: border-box; + padding: 10px 18px; + border-top: 1px solid #d9dce3; + background: rgb(250 251 253 / 96%); + box-shadow: 0 -10px 30px rgb(24 28 35 / 8%); + backdrop-filter: blur(14px); +} +.state-playback-expand { + position: absolute; + top: 0; + left: 50%; + border-radius: 999px; + background: white; + box-shadow: 0 4px 14px rgb(24 28 35 / 9%); + transform: translate(-50%, -50%); +} +.state-playback-bar { + display: flex; + min-width: 0; + align-items: center; + gap: 14px; +} +.state-playback-details { + display: grid; + gap: 14px; + padding: 14px 2px 16px; + border-bottom: 1px solid #dfe3eb; +} +.state-playback-details-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} +.state-playback-details-header > div { + min-width: 0; +} +.state-playback-details-header .eyebrow { + margin-bottom: 4px; +} +.state-playback-details-header h2 { + margin: 0; + font-size: 17px; +} +.state-playback-details-header p:last-child { + margin: 5px 0 0; + color: #7f8692; + font-size: 10px; + line-height: 1.45; +} +.state-playback-detail-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 7px; +} +.state-playback-detail-grid > div { + display: grid; + min-width: 0; + gap: 3px; + padding: 10px; + border: 1px solid #e1e5ec; + border-radius: 11px; + background: white; +} +.state-playback-detail-grid span, +.state-playback-detail-grid small { + overflow: hidden; + color: #858b96; + font-size: 8px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} +.state-playback-detail-grid span { + letter-spacing: 0.04em; + text-transform: uppercase; +} +.state-playback-detail-grid strong { + font-size: 12px; +} +.state-playback-footer.is-expanded .state-playback-bar { + padding-top: 10px; +} +.state-playback-controls { + display: flex; + flex: 0 0 auto; + align-items: center; + padding-left: 14px; + border-left: 1px solid #dfe3eb; +} +.state-playback-footer .state-player { + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} +.state-playback-timeline { + display: flex; + min-width: 0; + flex: 1; + gap: 6px; + overflow-x: auto; + padding: 5px 3px 7px; + scroll-behavior: smooth; + scrollbar-color: #cbd2df transparent; + scrollbar-width: thin; +} +.state-playback-step { + position: relative; + width: 58px; + height: 58px; + flex: 0 0 58px; + overflow: hidden; + padding: 2px; + border-radius: 11px; + background: white; + box-shadow: 0 3px 10px rgb(24 28 35 / 5%); +} +.state-playback-step svg { + width: 100%; + height: 100%; +} +.state-playback-step[aria-pressed='true'] { + border-color: #aebff0; + background: #edf1ff; + box-shadow: 0 0 0 2px rgb(91 127 229 / 16%); +} +@property --state-countdown-angle { + syntax: ''; + inherits: false; + initial-value: 360deg; +} +@keyframes state-playback-countdown { + to { + --state-countdown-angle: 0deg; + } +} +.state-playback-progress { + position: absolute; + bottom: 5px; + left: 5px; + width: 16px; + height: 16px; + border: 2px solid white; + border-radius: 50%; + background: conic-gradient(#4f72d8 var(--state-countdown-angle), white 0); + box-shadow: 0 1px 4px rgb(24 28 35 / 22%); + animation-name: state-playback-countdown; + animation-timing-function: linear; + animation-fill-mode: forwards; + pointer-events: none; +} +.body-workspace-header [data-slot='button'] { + align-self: center; + cursor: pointer; +} +.body-workspace-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-bottom: 18px; + padding: 31px 2px 16px; + border-bottom: 1px solid #d9dce3; +} +.avatar-name-input { + width: min(330px, 100%); + margin: 0; + padding: 1px 5px; + border: 1px solid transparent; + border-radius: 7px; + outline: 0; + background: transparent; + color: #17191d; + font-size: 26px; + font-weight: 800; + letter-spacing: -0.045em; +} +.avatar-name-input:hover { + border-color: #d9dce3; + background: white; +} +.avatar-name-input:focus { + border-color: #7895ee; + background: white; + box-shadow: 0 0 0 3px rgb(95 131 232 / 12%); +} +.body-workspace-header p:not(.eyebrow) { + margin: 6px 0 0; + color: #818792; + font-size: 11px; + line-height: 1.45; +} +.body-workspace-header [data-slot='button']:disabled { + cursor: not-allowed; + opacity: 0.4; +} +.avatar-shelf { + position: relative; + margin-bottom: 12px; + padding: 20px; + border: 1px solid #dcdfe6; + border-radius: 16px; + background: white; + box-shadow: 0 4px 14px rgb(24 28 35 / 5%); +} +.avatar-shelf-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; +} +.avatar-shelf-heading strong { + font-size: 11px; +} +.avatar-shelf-heading span { + color: #8d929c; + font-size: 8px; +} +.avatar-shelf .avatar-grid { + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 6px; +} +.avatar-shelf .avatar-card, +.avatar-shelf .avatar-add { + height: 74px; + min-height: 74px; + border-radius: 10px; +} +.avatar-sort-item { + min-width: 0; + will-change: transform; +} +.avatar-sort-item .avatar-card { + width: 100%; +} +.avatar-sort-item[data-dragging='true'] .avatar-card { + border-style: dashed; + box-shadow: none; +} +.avatar-shelf .avatar-card { + flex-direction: column; + align-items: stretch; + cursor: grab; +} +.avatar-shelf .avatar-card:active { + cursor: grabbing; +} +.avatar-shelf .avatar-card svg { + height: 48px; + flex: 0 1 48px; +} +.avatar-shelf .avatar-card > span { + min-height: 12px; + padding: 0 3px; + font-size: 8px; +} +.tabs { + position: sticky; + z-index: 5; + top: 12px; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 5px; + margin-bottom: 16px; + padding: 5px; + backdrop-filter: blur(14px); +} +.state-buttons button, +.button-row button, +.reset { + min-height: 36px; + font-size: 11px; +} +.color-field { + display: grid; + gap: 8px; + color: #3d424b; + font-size: 10px; + font-weight: 780; +} +.color-control { + display: flex; + align-items: center; + gap: 9px; + min-height: 42px; + padding: 5px 9px 5px 5px; + border: 1px solid #dcdfe6; + border-radius: 10px; + background: #f8f9fb; +} +.color-swatch { + width: 38px; + height: 30px; + padding: 2px; + border-radius: 7px; + cursor: pointer; +} +.color-control .color-value { + width: 86px; + height: 28px; + padding: 0 6px; + border: 0; + background: transparent; + box-shadow: none; + color: #656c77; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10px; + letter-spacing: 0.04em; +} +.ambient-motion-field { + grid-template-columns: minmax(0, 1fr) 170px; +} +.ambient-motion-field [data-slot='select-trigger'] { + height: 34px; + padding: 0 30px 0 10px; + border: 1px solid #d8dce5; + border-radius: 8px; + outline: none; + background: #fff; + color: #272b33; + font-size: 12px; +} +.ambient-motion-field [data-slot='select-trigger']:focus-visible { + border-color: #7897f4; + box-shadow: 0 0 0 3px rgb(91 127 229 / 14%); +} +.field-help { + margin: 9px 0 0; + color: #858a95; + font-size: 11px; + line-height: 1.45; +} +.color-panel { + position: relative; +} +.inherit-colors { + position: absolute; + top: 14px; + right: 14px; +} +.position-spacing { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid #eceef2; +} +.panel-inline-subtitle { + margin: 4px 0 0; + color: #858a95; + font-size: 10px; + font-weight: 500; + line-height: 1.45; +} + +.panel-stack { + display: grid; + gap: 14px; +} +.control-section { + display: grid; + gap: 14px; + padding: 20px; + border: 1px solid #dcdfe6; + border-radius: 16px; + background: white; + box-shadow: 0 4px 14px rgb(24 28 35 / 5%); +} +.control-section-header { + padding: 4px 5px 2px; +} +.control-section-header h2 { + margin: 0; + color: #343b49; + font-size: 12px; + font-weight: 850; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.control-section-header p { + margin: 5px 0 0; + color: #858c99; + font-size: 10px; + line-height: 1.45; +} +.control-section-separator { + opacity: 0.7; +} +.control-section-content { + display: grid; + gap: 10px; +} +.control-section .panel { + border-color: #e0e3e9; +} +.control-section-compact { + padding: 20px; + border-radius: 16px; +} +.control-section-compact .control-section-header { + padding-inline: 3px; +} +.body-tree { + display: grid; + gap: 6px; +} +.body-tree > button { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + height: auto; + min-height: 46px; + padding: 10px 12px; + text-align: left; + white-space: normal; +} +.body-tree > button[aria-pressed='true'] { + border-color: #7796ec; + background: #edf1ff; + color: #3f63c6; +} +.body-tree > button span:last-child { + display: grid; + min-width: 0; + gap: 2px; +} +.body-tree strong, +.body-tree small { + line-height: 1.25; +} +.body-tree small { + color: #858a95; + font-size: 9px; +} +.body-node-icon { + display: grid; + width: 34px; + height: 34px; + flex: 0 0 34px; + place-items: center; + color: #7c8492; +} +.body-node-icon-primary { + width: 40px; + height: 40px; + flex-basis: 40px; +} +.body-node-icon svg { + width: 100%; + height: 100%; +} +.body-node-icon svg path { + fill: currentColor; +} +.body-add { + display: grid; + gap: 7px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #eceef2; + color: #858a95; + font-size: 10px; + font-weight: 750; +} +.body-add > div { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 6px; +} +.body-node-actions button { + font-size: 9px; +} +.body-add .body-add-card { + height: 62px; + min-height: 62px; + padding: 4px 2px; + border-radius: 10px; + font-size: 8px; +} +.body-add .body-add-card svg { + width: 30px; + height: 30px; +} +.body-node-editor { + margin-top: 14px; + padding: 13px; + border: 1px solid #dde3f2; + border-radius: 13px; + background: #fafbff; +} +.body-node-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.body-node-actions > div { + display: flex; + gap: 6px; +} +.body-gizmo-help { + display: flex; + align-items: center; + gap: 8px; + margin: 10px 0 0; + color: #747c89; + font-size: 9px; + line-height: 1.4; +} +.body-transform-grid { + display: grid; + margin-top: 12px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} +.body-transform-grid h3 { + margin: 0 0 5px; + font-size: 10px; +} +.body-transform-grid .numeric-field { + grid-template-columns: 42px minmax(0, 1fr); +} +.body-transform-grid .scrub-label { + grid-column: 1; +} +.panel { + gap: 14px; + padding: 20px; + border: 1px solid #dcdfe6; + border-radius: 16px; + background: white; + box-shadow: 0 4px 14px rgb(24 28 35 / 5%); +} +.section-description { + margin: -2px 3px 2px; + color: #747c89; + font-size: 10px; + line-height: 1.55; +} +.panel.compact { + padding-top: 16px; +} +.surface-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 7px; +} +.surface-card { + display: grid; + min-width: 0; + min-height: 82px; + padding: 6px 4px 7px; + place-items: center; + color: #767c87; + cursor: pointer; + font-size: 9px; + font-weight: 780; + transition: + border-color 140ms ease, + background 140ms ease, + color 140ms ease, + transform 140ms ease; +} +.surface-card:hover { + border-color: #c7cfdf; + transform: translateY(-1px); +} +.surface-card[aria-pressed='true'] { + border-color: #7796ec; + background: #edf1ff; + color: #3f63c6; + box-shadow: 0 0 0 2px rgb(91 127 229 / 12%); +} +.surface-card svg { + width: 48px; + height: 48px; +} +.surface-card svg path { + fill: currentColor; +} +.surface-fields { + display: grid; + margin-top: 14px; + padding-top: 10px; + border-top: 1px solid #eceef2; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 14px; +} +.surface-fields .numeric-field { + grid-template-columns: minmax(0, 1fr) 72px; +} +.surface-fields .numeric-field + .numeric-field { + border-top: 0; +} +.panel-title h2, +.panel-title h3, +.panel-inline-title h2, +.panel-inline-title h3, +.preset-header h2, +.state-detail h2 { + margin: 0; + font-size: 15px; + letter-spacing: -0.015em; +} +.panel-title { + gap: 5px; + margin-bottom: 17px; + padding: 0; +} +.panel-title [data-slot='card-title'] { + line-height: inherit; +} +.panel-title [data-slot='card-description'] { + font-size: 11px; + line-height: 1.45; +} +.panel-title p { + margin: 5px 0 17px; + color: #858a95; + font-size: 11px; + line-height: 1.45; +} +.panel-inline-title, +.preset-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} +.eye-columns { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} +.eye-column h3 { + margin: 0 0 5px 4px; + color: #858a95; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; +} +.eye-columns .numeric-field { + grid-template-columns: minmax(0, 1fr) 72px; +} +.eye-columns .numeric-field + .numeric-field { + border-top: 0; +} +.preset-header > span { + color: #8a8f99; + font-size: 10px; +} + +.numeric-field { + display: grid; + align-items: center; + grid-template-columns: minmax(112px, 0.85fr) minmax(100px, 1fr) 72px; + gap: 10px; + min-height: 39px; +} +.numeric-field + .numeric-field { + border-top: 1px solid #f0f1f4; +} +.scrub-label { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 5px; + padding: 8px 4px; + cursor: ew-resize; + touch-action: none; + font-size: 11px; + font-weight: 720; + text-align: left; +} +.scrub-label span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.scrub-icon { + color: #7891dc; + font-size: 14px; + opacity: 0.65; +} +.scrub-label:hover .scrub-icon, +.scrub-label:active .scrub-icon { + opacity: 1; +} +.range { + width: 100%; + accent-color: #5b7fe5; + cursor: pointer; +} +.number-shell { + display: flex; + align-items: center; + overflow: hidden; + border: 1px solid #dcdfe6; + border-radius: 8px; + background: #f7f8fa; +} +.number-shell:focus-within { + border-color: #7592e9; + box-shadow: 0 0 0 3px rgb(91 127 229 / 12%); +} +.number-shell input { + width: 100%; + min-width: 0; + padding: 7px 0 7px 8px; + border: 0; + outline: 0; + background: transparent; + font-size: 10px; + font-variant-numeric: tabular-nums; +} +.number-shell span { + padding-right: 7px; + color: #8a8f99; + font-size: 9px; +} +.number-shell input::-webkit-inner-spin-button { + display: none; +} + +.link-button, +.icon-button { + display: grid; + width: 32px; + height: 32px; + padding: 7px; + place-items: center; +} +.link-button svg { + width: 16px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-width: 1.8; +} +.link-button[aria-pressed='true'] { + border-color: #b8c7f3; + background: #edf1ff; + color: #5075df; +} +.switch { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 12px; + padding-top: 13px; + border-top: 1px solid #eceef2; + font-size: 11px; + font-weight: 720; +} +.switch input { + accent-color: #5b7fe5; +} +.reset { + width: 100%; + margin-top: 14px; +} + +.expression-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; +} +.expression-sort-item, +.state-sort-item { + min-width: 0; +} +.expression-sort-item > *, +.state-sort-item > * { + width: 100%; +} +.expression-card, +.expression-add { + position: relative; + min-height: 76px; + overflow: hidden; + padding: 3px; + cursor: pointer; +} +.avatar-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} +.avatar-card, +.avatar-add { + position: relative; + min-height: 132px; + overflow: hidden; + padding: 7px; + cursor: pointer; +} +.avatar-card svg { + display: block; + width: 100%; + height: 105px; +} +.avatar-card > span { + display: block; + overflow: hidden; + padding: 5px 5px 3px; + font-size: 10px; + font-weight: 850; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} +.avatar-card[aria-pressed='true'] { + border-color: #5b7fe5; + box-shadow: 0 0 0 2px rgb(91 127 229 / 18%); +} +.creation-card { + display: grid; + place-items: center; + border-style: dashed; + color: #5b7fe5; +} +.creation-card svg { + width: 22px; + height: 22px; +} +.expression-card svg { + display: block; + width: 100%; + height: 64px; +} +.expression-card > span { + position: absolute; + right: 5px; + bottom: 4px; + padding: 2px 4px; + border-radius: 5px; + background: rgb(255 255 255 / 78%); + font-size: 8px; + font-weight: 850; +} +.expression-card[aria-pressed='true'] { + border-color: #5b7fe5; + box-shadow: 0 0 0 2px rgb(91 127 229 / 18%); +} +.expression-add { + border-radius: 10px; +} +.preview-head { + fill: #5b7fe5; +} +.preview-eye { + fill: #111316; +} +.button-row { + display: flex; + gap: 8px; + margin-top: 14px; +} +.button-row button { + flex: 1; +} +.state-groups { + display: grid; + gap: 15px; +} +.state-groups strong { + display: block; + margin-bottom: 7px; + color: #888d97; + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.state-buttons { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; +} +.state-buttons .state-card > span { + max-width: calc(100% - 10px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.state-buttons .state-card { + height: 76px; + min-height: 76px; +} +.state-buttons .state-card[aria-pressed='true'] { + border-color: #aebff0; + background: #edf1ff; + color: #4f72d8; +} +.state-detail p { + margin: 7px 0 14px; + color: #7f848e; + font-size: 11px; + line-height: 1.5; +} +.state-detail-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +.state-detail-header > div { + min-width: 0; +} +.state-step-preview { + display: grid; + gap: 4px; +} +.state-step-preview > small { + color: #8a909b; + font-size: 8px; + font-weight: 750; + text-align: center; +} +.state-expression-section, +.state-blink-section { + padding-top: 16px; + border-top: 1px solid #eceef2; +} +.state-blink-section { + margin-top: 18px; +} +.state-section-heading { + display: flex; + align-items: start; + justify-content: space-between; + gap: 12px; + margin-bottom: 11px; +} +.state-section-heading h3 { + margin: 0; + font-size: 12px; +} +.state-section-heading p { + margin: 4px 0 0; + font-size: 10px; +} +.state-section-heading > span { + flex: 0 0 auto; + padding: 4px 7px; + border-radius: 999px; + background: #eef1f7; + color: #6f7785; + font-size: 8px; + font-weight: 800; + text-transform: uppercase; +} +.state-expression-grid { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} +.state-logic-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.state-logic-grid > div { + display: grid; + gap: 3px; + padding: 11px; + border: 1px solid #e1e4ea; + border-radius: 11px; + background: #f7f8fa; +} +.state-logic-grid span, +.state-logic-grid small { + color: #858b96; + font-size: 8px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.sequence-workspace-scroll { + padding-bottom: 6px; +} +.sequence-workspace [data-slot='card'] { + gap: 14px; +} +.sequence-timeline { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 3px; +} +.sequence-step { + position: relative; + flex: 0 0 90px; + cursor: grab; +} +.sequence-step:active { + cursor: grabbing; +} +.sequence-step > [data-slot='button'] { + position: relative; + display: block; + width: 100%; + height: 94px; + overflow: hidden; + padding: 4px; +} +.sequence-step > [data-slot='button'][aria-pressed='true'] { + border-color: #5b7fe5; + box-shadow: 0 0 0 2px rgb(91 127 229 / 18%); +} +.sequence-step svg:not(.sequence-grip) { + display: block; + width: 100%; + height: 76px; +} +.sequence-step .sequence-grip { + position: absolute; + z-index: 2; + top: 6px; + left: 5px; + width: 13px; + height: 13px; + padding: 2px; + border-radius: 4px; + background: rgb(255 255 255 / 82%); + color: #7f8794; +} +.sequence-step span, +.sequence-step small { + position: absolute; + z-index: 2; + bottom: 5px; + padding: 2px 4px; + border-radius: 5px; + background: rgb(255 255 255 / 82%); + font-size: 8px; + font-weight: 850; +} +.sequence-step span { + right: 5px; +} +.sequence-step small { + left: 5px; + color: #727a87; +} +.sequence-empty { + margin: 0; + padding: 24px 12px; + border: 1px dashed #d6dae3; + border-radius: 10px; + color: #858b96; + font-size: 11px; + text-align: center; +} +.sequence-step-settings { + display: grid; + gap: 10px; + margin-top: 4px; + padding-top: 14px; + border-top: 1px solid #eceef2; +} + +.avatar-card[aria-pressed='true'], +.expression-card[aria-pressed='true'], +.sequence-step > [data-slot='button'][aria-pressed='true'], +.surface-card[aria-pressed='true'], +.body-tree > button[aria-pressed='true'] { + border-color: #aebff0; + background: #edf1ff; + color: #4f72d8; + box-shadow: 0 0 0 2px rgb(91 127 229 / 16%); +} +.sequence-transition-field { + display: grid; + grid-template-columns: minmax(0, 1fr) 170px; +} +.sequence-expression-library { + max-height: 264px; + overflow-y: auto; + padding: 2px; +} +.state-logic-grid strong { + color: #303640; + font-size: 13px; +} +.state-logic-grid small { + font-size: 7px; + font-weight: 650; + letter-spacing: 0; + text-transform: none; +} + +.dialog-fields { + display: grid; + gap: 12px; +} +.dialog-group { + padding: 13px; + border: 1px solid #e0e2e7; + border-radius: 14px; + background: white; +} +.dialog-group h3 { + margin: 0 0 7px; + font-size: 11px; +} +.dialog-group .panel-inline-title { + margin-bottom: 7px; +} +.dialog-group .panel-inline-title h3 { + margin: 0; +} +.dialog-actions-main { + display: flex; + margin-left: auto; + gap: 8px; +} + +@media (max-width: 980px) { + .studio { + grid-template-columns: 1fr; + } + .stage-column { + position: relative; + height: min(72vh, 680px); + min-height: 480px; + } + .inspector { + border-top: 1px solid #d9dce3; + border-left: 0; + } + .avatar-wrap { + width: min(62vh, 82vw); + } +} + +@media (max-width: 600px) { + .stage-column { + min-height: 420px; + padding: 18px; + } + .photo-capture-bar { + bottom: 48px; + } + .photo-capture-button { + min-width: 168px; + } + .brand { + top: 18px; + left: 20px; + } + .inspector { + padding: 22px 14px 40px; + } + .numeric-field { + grid-template-columns: 100px minmax(76px, 1fr) 66px; + gap: 7px; + } + .expression-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .avatar-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .avatar-shelf .avatar-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + padding-right: 0; + } + .avatar-edit { + position: static; + width: 100%; + min-height: 36px; + margin-top: 8px; + } + .surface-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .workspace-header-actions .state-player > span { + display: none; + } +} diff --git a/src/surfaces.ts b/src/surfaces.ts new file mode 100644 index 0000000..98295fd --- /dev/null +++ b/src/surfaces.ts @@ -0,0 +1,665 @@ +import type { Point3 } from './geometry' + +export type SurfaceType = + 'sphere' | 'mickey' | 'cursor' | 'cube' | 'capsule' | 'cylinder' | 'cone' | 'diamond' + +export type SurfaceConfig = { + type: SurfaceType + width: number + height: number + depth: number + roundness: number + morphRoundness?: number + tipRoundness?: number + baseRoundness?: number +} + +export type SurfaceSample = { + point: Point3 + normal: Point3 +} + +export const surfacePresets: Record = { + sphere: { type: 'sphere', width: 240, height: 240, depth: 240, roundness: 1 }, + mickey: { type: 'mickey', width: 220, height: 210, depth: 145, roundness: 1 }, + cursor: { type: 'cursor', width: 175, height: 260, depth: 145, roundness: 0 }, + cube: { type: 'cube', width: 245, height: 245, depth: 220, roundness: 0 }, + capsule: { type: 'capsule', width: 205, height: 270, depth: 205, roundness: 1 }, + cylinder: { + type: 'cylinder', + width: 235, + height: 250, + depth: 215, + roundness: 0.45, + morphRoundness: 0, + }, + cone: { + type: 'cone', + width: 250, + height: 265, + depth: 225, + roundness: 0, + morphRoundness: 0, + tipRoundness: 0.55, + baseRoundness: 0.45, + }, + diamond: { type: 'diamond', width: 235, height: 260, depth: 215, roundness: 0 }, +} + +export const surfaceLabels: Record = { + sphere: 'Sphère', + mickey: 'Mickey', + cursor: 'Curseur', + cube: 'Cube', + capsule: 'Capsule', + cylinder: 'Cylindre', + cone: 'Cône', + diamond: 'Diamant', +} + +const signedPower = (value: number, exponent: number) => + Math.sign(value) * Math.abs(value) ** exponent + +const superellipsoid = ( + longitude: number, + latitude: number, + width: number, + height: number, + depth: number, + verticalExponent: number, + horizontalExponent: number +): Point3 => { + const latitudeCosine = signedPower(Math.cos(latitude), verticalExponent) + return [ + (width / 2) * latitudeCosine * signedPower(Math.sin(longitude), horizontalExponent), + (height / 2) * signedPower(Math.sin(latitude), verticalExponent), + (depth / 2) * latitudeCosine * signedPower(Math.cos(longitude), horizontalExponent), + ] +} + +const capsule = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => { + const radiusX = config.width / 2 + const radiusZ = config.depth / 2 + const capRadius = Math.min(radiusX, config.height / 2) + const straightHalf = Math.max(0, (config.height - capRadius * 2) / 2) + const meridianLength = straightHalf * 2 + Math.PI * capRadius + const distance = ((latitude + Math.PI / 2) / Math.PI) * meridianLength + let radial = radiusX + let y = 0 + + if (distance < (Math.PI * capRadius) / 2) { + const angle = -Math.PI / 2 + distance / capRadius + radial = radiusX * Math.cos(angle) + y = -straightHalf + capRadius * Math.sin(angle) + } else if (distance <= (Math.PI * capRadius) / 2 + straightHalf * 2) { + y = -straightHalf + distance - (Math.PI * capRadius) / 2 + } else { + const angle = (distance - (Math.PI * capRadius) / 2 - straightHalf * 2) / capRadius + radial = radiusX * Math.cos(angle) + y = straightHalf + capRadius * Math.sin(angle) + } + + const depthScale = radiusX ? radiusZ / radiusX : 1 + return [radial * Math.sin(longitude), y, radial * depthScale * Math.cos(longitude)] +} + +const clampRoundness = (roundness: number | undefined) => Math.max(0, Math.min(2, roundness ?? 0)) + +const diamondExponent = (config: SurfaceConfig) => 1 + clampRoundness(config.roundness) / 2 + +const MIN_CUBE_SURFACE_POWER = 0.04 + +const cubeExponent = (config: SurfaceConfig) => { + if (config.roundness <= 0) return Infinity + // The implicit superellipsoid power moves from an almost-flat cube to an ellipsoid. + const surfacePower = + MIN_CUBE_SURFACE_POWER + (clampRoundness(config.roundness) / 2) * (1 - MIN_CUBE_SURFACE_POWER) + return 2 / surfacePower +} + +const lpSurface = ( + config: SurfaceConfig, + longitude: number, + latitude: number, + exponent: number +): Point3 => { + const sphereX = Math.cos(latitude) * Math.sin(longitude) + const sphereY = Math.sin(latitude) + const sphereZ = Math.cos(latitude) * Math.cos(longitude) + const length = Number.isFinite(exponent) + ? (Math.abs(sphereX) ** exponent + + Math.abs(sphereY) ** exponent + + Math.abs(sphereZ) ** exponent) ** + (1 / exponent) || 1 + : Math.max(Math.abs(sphereX), Math.abs(sphereY), Math.abs(sphereZ)) || 1 + return [ + (config.width / 2) * (sphereX / length), + (config.height / 2) * (sphereY / length), + (config.depth / 2) * (sphereZ / length), + ] +} + +const diamond = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => { + return lpSurface(config, longitude, latitude, diamondExponent(config)) +} + +const cube = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => + lpSurface(config, longitude, latitude, cubeExponent(config)) + +const MAX_CONE_TIP_FRACTION = 0.24 +const MAX_CONE_BASE_FRACTION = 0.2 +const MAX_CYLINDER_EDGE_FRACTION = 0.22 + +type RadialProfile = { + radiusScale: number + verticalProgress: number +} + +const morphProgress = (config: SurfaceConfig) => clampRoundness(config.morphRoundness) / 2 + +const morphProfileToEllipsoid = ( + config: SurfaceConfig, + progress: number, + profile: RadialProfile +): RadialProfile => { + const amount = morphProgress(config) + const clampedProgress = Math.max(0, Math.min(1, progress)) + const ellipsoidRadius = Math.sin(clampedProgress * Math.PI) + const ellipsoidVerticalProgress = (1 - Math.cos(clampedProgress * Math.PI)) / 2 + return { + radiusScale: profile.radiusScale + (ellipsoidRadius - profile.radiusScale) * amount, + verticalProgress: + profile.verticalProgress + (ellipsoidVerticalProgress - profile.verticalProgress) * amount, + } +} + +const cubic = ( + start: number, + firstControl: number, + secondControl: number, + end: number, + progress: number +) => { + const inverse = 1 - progress + return ( + inverse ** 3 * start + + 3 * inverse * inverse * progress * firstControl + + 3 * inverse * progress * progress * secondControl + + progress ** 3 * end + ) +} + +const coneRounding = (config: SurfaceConfig) => ({ + tipFraction: (config.tipRoundness ?? 0) * MAX_CONE_TIP_FRACTION, + baseFraction: (config.baseRoundness ?? 0) * MAX_CONE_BASE_FRACTION, +}) + +/** Cylinder half-profile with a quarter-round transition at both caps. */ +const cylinderProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => { + const clampedProgress = Math.max(0, Math.min(1, progress)) + const edgeFraction = config.roundness * MAX_CYLINDER_EDGE_FRACTION + if (edgeFraction <= 0) { + return { + radiusScale: 1, + verticalProgress: (Math.sin((clampedProgress - 0.5) * Math.PI) + 1) / 2, + } + } + + if (clampedProgress < edgeFraction) { + const angle = -Math.PI / 2 + (clampedProgress / edgeFraction) * (Math.PI / 2) + return { + radiusScale: 1 - edgeFraction + edgeFraction * Math.cos(angle), + verticalProgress: (edgeFraction + edgeFraction * Math.sin(angle)) / 2, + } + } + + if (clampedProgress > 1 - edgeFraction) { + const angle = ((clampedProgress - (1 - edgeFraction)) / edgeFraction) * (Math.PI / 2) + return { + radiusScale: 1 - edgeFraction + edgeFraction * Math.cos(angle), + verticalProgress: 1 - edgeFraction / 2 + (edgeFraction * Math.sin(angle)) / 2, + } + } + + const middleProgress = (clampedProgress - edgeFraction) / (1 - edgeFraction * 2) + return { + radiusScale: 1, + verticalProgress: edgeFraction / 2 + middleProgress * (1 - edgeFraction), + } +} + +const morphedCylinderProfileAt = (config: SurfaceConfig, progress: number) => + morphProfileToEllipsoid(config, progress, cylinderProfileAt(config, progress)) + +const radiusScaleAtVerticalProgress = ( + config: SurfaceConfig, + verticalProgress: number, + profileAt: (config: SurfaceConfig, progress: number) => RadialProfile +) => { + const progress = Math.max(0, Math.min(1, verticalProgress)) + let lower = 0 + let upper = 1 + for (let iteration = 0; iteration < 14; iteration += 1) { + const candidate = (lower + upper) / 2 + if (profileAt(config, candidate).verticalProgress < progress) lower = candidate + else upper = candidate + } + return profileAt(config, (lower + upper) / 2).radiusScale +} + +/** Rounded half-profile revolved around the cone's vertical axis. */ +const coneProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => { + const clampedProgress = Math.max(0, Math.min(1, progress)) + const { tipFraction, baseFraction } = coneRounding(config) + + if (baseFraction > 0 && clampedProgress < baseFraction) { + const curveProgress = clampedProgress / baseFraction + return { + radiusScale: cubic( + 1 - baseFraction, + 1, + 1 - baseFraction / 2, + 1 - baseFraction, + curveProgress + ), + verticalProgress: cubic(0, 0, baseFraction / 2, baseFraction, curveProgress), + } + } + + if (tipFraction > 0 && clampedProgress > 1 - tipFraction) { + const curveProgress = (clampedProgress - (1 - tipFraction)) / tipFraction + return { + radiusScale: cubic(tipFraction, tipFraction / 2, tipFraction / 4, 0, curveProgress), + verticalProgress: cubic(1 - tipFraction, 1 - tipFraction / 2, 1, 1, curveProgress), + } + } + + return { + radiusScale: 1 - clampedProgress, + verticalProgress: clampedProgress, + } +} + +const morphedConeProfileAt = (config: SurfaceConfig, progress: number) => + morphProfileToEllipsoid(config, progress, coneProfileAt(config, progress)) + +export const cursorLayout = (config: SurfaceConfig) => { + const coneHeight = config.height * 0.36 + const bodyHeight = config.height - coneHeight + return { + coneApexY: -config.height / 2, + coneBaseY: -config.height / 2 + coneHeight, + bodyHeight, + bodyCenterY: config.height / 2 - bodyHeight / 2, + bodyWidth: config.width * 0.54, + bodyDepth: config.depth * 0.62, + } +} + +export const surfacePointAt = ( + config: SurfaceConfig, + longitude: number, + latitude: number +): Point3 => { + const { width, height, depth } = config + switch (config.type) { + case 'sphere': + case 'mickey': + return superellipsoid(longitude, latitude, width, height, depth, 1, 1) + case 'cube': + return cube(config, longitude, latitude) + case 'cylinder': { + const progress = (latitude + Math.PI / 2) / Math.PI + const profile = morphedCylinderProfileAt(config, progress) + return [ + (width / 2) * profile.radiusScale * Math.sin(longitude), + -height / 2 + height * profile.verticalProgress, + (depth / 2) * profile.radiusScale * Math.cos(longitude), + ] + } + case 'cursor': { + const layout = cursorLayout(config) + const progress = (latitude + Math.PI / 2) / Math.PI + const bodyConfig = { + ...config, + width: layout.bodyWidth, + height: layout.bodyHeight, + depth: layout.bodyDepth, + } + const profile = cylinderProfileAt(bodyConfig, progress) + return [ + (layout.bodyWidth / 2) * profile.radiusScale * Math.sin(longitude), + layout.bodyCenterY - layout.bodyHeight / 2 + layout.bodyHeight * profile.verticalProgress, + (layout.bodyDepth / 2) * profile.radiusScale * Math.cos(longitude), + ] + } + case 'diamond': + return diamond(config, longitude, latitude) + case 'capsule': + return capsule(config, longitude, latitude) + case 'cone': { + const progress = (latitude + Math.PI / 2) / Math.PI + const profile = morphedConeProfileAt(config, progress) + return [ + (width / 2) * profile.radiusScale * Math.sin(longitude), + height / 2 - height * profile.verticalProgress, + (depth / 2) * profile.radiusScale * Math.cos(longitude), + ] + } + } +} + +const subtract = (left: Point3, right: Point3): Point3 => [ + left[0] - right[0], + left[1] - right[1], + left[2] - right[2], +] + +const normalize = ([x, y, z]: Point3): Point3 => { + const length = Math.hypot(x, y, z) || 1 + return [x / length, y / length, z / length] +} + +const normalFromTangents = ( + config: SurfaceConfig, + longitudeTangent: Point3, + latitudeTangent: Point3 +) => { + const orientation = config.type === 'cone' ? -1 : 1 + return normalize([ + orientation * + (longitudeTangent[1] * latitudeTangent[2] - longitudeTangent[2] * latitudeTangent[1]), + orientation * + (longitudeTangent[2] * latitudeTangent[0] - longitudeTangent[0] * latitudeTangent[2]), + orientation * + (longitudeTangent[0] * latitudeTangent[1] - longitudeTangent[1] * latitudeTangent[0]), + ]) +} + +const tangentNormalAt = (config: SurfaceConfig, longitude: number, latitude: number) => { + const epsilon = 0.0005 + if (config.type === 'cone' && latitude >= Math.PI / 2 - epsilon) return [0, -1, 0] as Point3 + const longitudeBefore = surfacePointAt(config, longitude - epsilon, latitude) + const longitudeAfter = surfacePointAt(config, longitude + epsilon, latitude) + const latitudeBefore = surfacePointAt( + config, + longitude, + Math.max(-Math.PI / 2, latitude - epsilon) + ) + const latitudeAfter = surfacePointAt(config, longitude, Math.min(Math.PI / 2, latitude + epsilon)) + return normalFromTangents( + config, + subtract(longitudeAfter, longitudeBefore), + subtract(latitudeAfter, latitudeBefore) + ) +} + +const signedMagnitude = (value: number, exponent: number) => + Math.sign(value) * Math.abs(value) ** exponent + +const lpNormal = (config: SurfaceConfig, point: Point3, exponent: number): Point3 => { + const radiusX = config.width / 2 || 1 + const radiusY = config.height / 2 || 1 + const radiusZ = config.depth / 2 || 1 + return normalize([ + signedMagnitude(point[0] / radiusX, exponent - 1) / radiusX, + signedMagnitude(point[1] / radiusY, exponent - 1) / radiusY, + signedMagnitude(point[2] / radiusZ, exponent - 1) / radiusZ, + ]) +} + +const diamondNormal = (config: SurfaceConfig, point: Point3): Point3 => + lpNormal(config, point, diamondExponent(config)) + +const cubeNormal = (config: SurfaceConfig, point: Point3): Point3 => { + const exponent = cubeExponent(config) + if (Number.isFinite(exponent)) return lpNormal(config, point, exponent) + + const normalized = [ + point[0] / (config.width / 2 || 1), + point[1] / (config.height / 2 || 1), + point[2] / (config.depth / 2 || 1), + ] as Point3 + const dominantAxis = normalized.reduce( + (largest, value, index) => (Math.abs(value) > Math.abs(normalized[largest]) ? index : largest), + 0 + ) + const normal: Point3 = [ + dominantAxis === 0 ? Math.sign(normalized[0]) : 0, + dominantAxis === 1 ? Math.sign(normalized[1]) : 0, + dominantAxis === 2 ? Math.sign(normalized[2]) : 0, + ] + return normal +} + +const lpFrontSample = ( + config: SurfaceConfig, + x: number, + y: number, + exponent: number, + normalAt: (config: SurfaceConfig, point: Point3) => Point3 +): SurfaceSample => { + const radiusX = config.width / 2 || 1 + const radiusY = config.height / 2 || 1 + const radiusZ = config.depth / 2 || 1 + if (!Number.isFinite(exponent)) { + const point: Point3 = [ + Math.max(-radiusX, Math.min(radiusX, x)), + Math.max(-radiusY, Math.min(radiusY, y)), + radiusZ, + ] + return { point, normal: normalAt(config, point) } + } + + const normalizedY = Math.max(-1, Math.min(1, y / radiusY)) + const availableX = Math.max(0, 1 - Math.abs(normalizedY) ** exponent) ** (1 / exponent) + const surfaceX = Math.max(-radiusX * availableX, Math.min(radiusX * availableX, x)) + const normalizedX = surfaceX / radiusX + const normalizedZ = + Math.max(0, 1 - Math.abs(normalizedX) ** exponent - Math.abs(normalizedY) ** exponent) ** + (1 / exponent) + const point: Point3 = [surfaceX, normalizedY * radiusY, radiusZ * normalizedZ] + return { point, normal: normalAt(config, point) } +} + +const ellipsoidFrontSample = ( + x: number, + y: number, + radiusX: number, + radiusY: number, + radiusZ: number, + centerY = 0 +): SurfaceSample => { + const localY = y - centerY + const remaining = Math.max(0, 1 - (x / (radiusX || 1)) ** 2 - (localY / (radiusY || 1)) ** 2) + const z = radiusZ * Math.sqrt(remaining) + return { + point: [x, y, z], + normal: normalize([ + x / (radiusX * radiusX || 1), + localY / (radiusY * radiusY || 1), + z / (radiusZ * radiusZ || 1), + ]), + } +} + +const radialProfileFrontSample = ( + config: SurfaceConfig, + x: number, + y: number, + profileAt: (config: SurfaceConfig, progress: number) => RadialProfile, + verticalDirection: -1 | 1 +): SurfaceSample => { + const radiusX = config.width / 2 || 1 + const radiusZ = config.depth / 2 || 1 + const verticalProgress = Math.max(0, Math.min(1, 0.5 + verticalDirection * (y / config.height))) + const radialScale = radiusScaleAtVerticalProgress(config, verticalProgress, profileAt) + const sectionRadiusX = radiusX * radialScale + const sectionRadiusZ = radiusZ * radialScale + const surfaceX = Math.max(-sectionRadiusX, Math.min(sectionRadiusX, x)) + const remaining = sectionRadiusX > 0 ? Math.max(0, 1 - (surfaceX / sectionRadiusX) ** 2) : 0 + const z = sectionRadiusZ * Math.sqrt(remaining) + const derivativeStep = 0.0001 + const previousProgress = Math.max(0, verticalProgress - derivativeStep) + const nextProgress = Math.min(1, verticalProgress + derivativeStep) + const previousScale = radiusScaleAtVerticalProgress(config, previousProgress, profileAt) + const nextScale = radiusScaleAtVerticalProgress(config, nextProgress, profileAt) + const scaleDerivative = (nextScale - previousScale) / (nextProgress - previousProgress || 1) + const radialRemainder = Math.max(Math.sqrt(remaining), 0.0001) + const depthRatio = radiusZ / radiusX + const depthXDerivative = (-depthRatio * surfaceX) / (sectionRadiusX * radialRemainder || 1) + const depthYDerivative = + (verticalDirection * radiusZ * scaleDerivative) / (config.height * radialRemainder || 1) + return { + point: [surfaceX, y, z], + normal: normalize([-depthXDerivative, -depthYDerivative, 1]), + } +} + +/** Project canonical face coordinates onto a primitive's front-facing sheet. */ +export const surfaceFrontSampleAt = ( + config: SurfaceConfig, + x: number, + y: number +): SurfaceSample => { + const radiusX = config.width / 2 || 1 + const radiusY = config.height / 2 || 1 + const radiusZ = config.depth / 2 || 1 + + switch (config.type) { + case 'sphere': + case 'mickey': + return ellipsoidFrontSample(x, y, radiusX, radiusY, radiusZ) + + case 'cube': + return lpFrontSample(config, x, y, cubeExponent(config), cubeNormal) + + case 'capsule': { + const capRadiusY = Math.min(radiusX, radiusY) + const straightHalf = Math.max(0, radiusY - capRadiusY) + const capCenterY = y < -straightHalf ? -straightHalf : y > straightHalf ? straightHalf : y + return ellipsoidFrontSample(x, y, radiusX, capRadiusY, radiusZ, capCenterY) + } + + case 'cylinder': + return radialProfileFrontSample(config, x, y, morphedCylinderProfileAt, 1) + + case 'cursor': { + const layout = cursorLayout(config) + const bodyConfig = { + ...config, + width: layout.bodyWidth, + height: layout.bodyHeight, + depth: layout.bodyDepth, + } + const sample = radialProfileFrontSample( + bodyConfig, + x, + y - layout.bodyCenterY, + cylinderProfileAt, + 1 + ) + return { + point: [sample.point[0], sample.point[1] + layout.bodyCenterY, sample.point[2]], + normal: sample.normal, + } + } + + case 'cone': + return radialProfileFrontSample(config, x, y, morphedConeProfileAt, -1) + + case 'diamond': + return lpFrontSample(config, x, y, diamondExponent(config), diamondNormal) + } +} + +export const surfaceNormalAt = ( + config: SurfaceConfig, + longitude: number, + latitude: number +): Point3 => { + const point = surfacePointAt(config, longitude, latitude) + + // An ellipsoid has a cheap exact normal. This is also the overwhelmingly + // common path for the default spherical head. + if (config.type === 'sphere' || config.type === 'mickey') { + const halfWidth = config.width / 2 || 1 + const halfHeight = config.height / 2 || 1 + const halfDepth = config.depth / 2 || 1 + return normalize([ + point[0] / (halfWidth * halfWidth), + point[1] / (halfHeight * halfHeight), + point[2] / (halfDepth * halfDepth), + ]) + } + + if (config.type === 'cylinder' && config.roundness <= 0 && (config.morphRoundness ?? 0) <= 0) { + return normalize([ + Math.sin(longitude) / (config.width / 2 || 1), + 0, + Math.cos(longitude) / (config.depth / 2 || 1), + ]) + } + + if (config.type === 'diamond') { + return diamondNormal(config, point) + } + + if (config.type === 'cube') { + return cubeNormal(config, point) + } + + return tangentNormalAt(config, longitude, latitude) +} + +export const surfaceSampleAt = ( + config: SurfaceConfig, + longitude: number, + latitude: number +): SurfaceSample => { + const point = surfacePointAt(config, longitude, latitude) + + if (config.type === 'sphere' || config.type === 'mickey') { + const halfWidth = config.width / 2 || 1 + const halfHeight = config.height / 2 || 1 + const halfDepth = config.depth / 2 || 1 + return { + point, + normal: normalize([ + point[0] / (halfWidth * halfWidth), + point[1] / (halfHeight * halfHeight), + point[2] / (halfDepth * halfDepth), + ]), + } + } + + if (config.type === 'cylinder' && config.roundness <= 0 && (config.morphRoundness ?? 0) <= 0) { + return { + point, + normal: normalize([ + Math.sin(longitude) / (config.width / 2 || 1), + 0, + Math.cos(longitude) / (config.depth / 2 || 1), + ]), + } + } + + if (config.type === 'diamond') { + return { + point, + normal: diamondNormal(config, point), + } + } + + if (config.type === 'cube') { + return { + point, + normal: cubeNormal(config, point), + } + } + + return { + point, + normal: tangentNormalAt(config, longitude, latitude), + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e443020 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "paths": { + "@/*": ["./src/*"] + }, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "types": ["vite/client", "vitest/globals"] + }, + "include": ["src", "vite.config.ts", "vitest.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..153172a --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,23 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import babel from '@rolldown/plugin-babel' +import react, { reactCompilerPreset } from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { defineConfig } from 'vite' + +const root = path.dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + root, + plugins: [react(), babel({ presets: [reactCompilerPreset()] }), tailwindcss()], + resolve: { + alias: { + '@': path.join(root, 'src'), + }, + }, + build: { + outDir: path.join(root, 'dist'), + emptyOutDir: true, + }, +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..e505796 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + include: ['src/__tests__/**/*-test.ts'], + }, +}) diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..c90e4f3 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,1992 @@ +# 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