From 86b7ee992cb9062700b45527667add7225f759df Mon Sep 17 00:00:00 2001 From: Tonio Date: Sat, 19 Sep 2026 06:30:14 -0700 Subject: [PATCH] feat(onboarding): ClipLab sleepy-to-wake hero and step hand-offs (#13629) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents have a persistent visual identity (#13171): a ClipLab character in one of 17 palettes, rendered as cached PNGs in lists and as a live character in larger placements. > - The onboarding wizard is where a person meets that identity first, and it showed a stock ClipLab expression on the previous engine while the rest of the app would show a different character on a newer one. > - The wizard's steps also cut from one screen to the next, so the arc read as separate pages rather than one walk. > - This pull request puts one character on one engine everywhere, gives the wizard's hero the studio's sleepy → wink → idle sequence on Review, and hands the steps over inside one presence. > - The benefit is that what wakes on Review is exactly what the agent looks like on the dashboard afterwards, and the walk to it reads as one screen changing. ## Linked Issues or Issue Description Refs #13171, now merged into master. This PR contains the onboarding and ClipLab update on top of that foundation. Original feature work by @tonio-alucema; merge preparation preserves the original commits. **Problem or motivation** The onboarding hero and the app's avatars were two different characters on two different ClipLab engines. Steps 1 → 4 of the wizard cut between screens, and the wizard mounted cold when a cloud-managed workspace arrived from Cloud's naming screen. **Proposed solution** Vendor ClipLab v0.2.0 as the shared engine and render one studio-exported character from it in every palette, for every pose and size. Play the export's one-shot wake on Review with the palette fading in over the gray dormant loop. Hand steps over inside one presence so the footer slides instead of jumping, and play the arrival half of that hand-off when the wizard opens directly on the agent step. **Alternatives considered** Exporting mp4/webm loops per size: no cursor following, no clean alpha, and the palette "colour in" is a runtime blend. Minting a `cap-v2` character version: nothing had shipped `cap-v1`, so the artwork is regenerated in place instead of migrated. Keeping the separately vendored runtime bundle for the hero: two engines and two characters in one app. ## What Changed - `packages/shared/src/cliplab`: re-vendored from ClipLab v0.2.0 (`987b6db0`) with the Paperclip adaptations replayed (optional graphics backend for the Node SVG snapshot path, supersampled live textures, character framing, deterministic SVG id prefixes); new upstream `particles.ts`. - `packages/shared/src/cliplab/character.ts`: the studio export, mirrored from `ui/src/assets/cliplab/onboarding.character.json` by `scripts/sync-cliplab-character.mjs` (drift caught by `check:token-gates`). `characterDefinition` builds every palette from it; the resting portrait is its idle beat. - `OnboardingCharacter`: gray `sleepy` loop through the agent and connect steps; on Review the one-shot sleepy → wink → idle plays on two lock-step canvases while the palette fades in, then the `idle` loop. Body-follows the pointer, page-scoped. 160px in the wizard. - `OnboardingWizard`: steps 1 → 2 → 3 → 4 hand over inside one `AnimatePresence` (departing content fades and gives its room back; arriving content opens its room then fills); the hero has a room that opens on the walk into the agent step; opening directly on the agent step plays the arrival half; the self-hosted naming step uses the arc's label and field. - Motion vocabulary in `onboarding-motion.ts` (`stepContentMotion`, `ledeMotion`, `heroRoomMotion`, `heroRoomArrival`, `titleSwapMotion`). - Storybook: `Onboarding / Character` (Wake Up), `Onboarding / Agent arc` walkable from the naming step plus `Arrive From Cloud`; the companies fixture answers the wizard's create call with a company. - Uses the shared runtime for onboarding; `doc/agent-personas.md` documents the shared character. - Releases both onboarding canvases after partial startup or transition failure. Registers each canvas before seeking so synchronous render errors can release it. Six component tests cover these failures and palette changes before or during wake. - Refreshes both sleeping canvases when the palette changes, including a palette change in the same render as wake. - Moves choreography values into the CSS token layer and preserves the shared motion catalog drift check across the imported stylesheet. - Repairs the static Storybook avatar route and uses accessible heading names/current button labels in the wizard play functions. - Closes the lazy avatar worker pool during application shutdown. ## Verification - Merge-preparation checks: `pnpm -r typecheck`, `pnpm build`, `pnpm build-storybook`, and `pnpm check:token-gates` pass. The final UI typecheck and 123 focused onboarding, lifecycle, and token catalog tests pass. All 55 checks on final head `b4f5e201a1564083d163abc6f93f5b3da06ccefd` pass, including the full sharded test suite, runner verification, and all eight browser shards ([CI run](https://github.com/paperclipai/paperclip/actions/runs/35445430535)). The duplicate monolithic local `pnpm test:run` was stopped after CI completed; it is not claimed as a separate completed local run. - Chromium walkthrough: palette change, wake, return to sleep, WebGL failure fallback, Review step hand-offs and cloud arrival pass with normal and reduced motion; no browser errors. The signoff happy-path browser test also passes against a disposable instance. - The final CI run confirms the catalog fix and a passing signoff browser shard. The earlier signoff failure was a heartbeat-run availability timeout; the focused local reproduction and final CI passed without signoff code changes. - Original author verification: - `pnpm check:token-gates` (includes the new character sync check); shared, server avatar/persona (17) and UI onboarding/persona (137) suites pass; `pnpm build-storybook` packages all 3,564 avatar PNGs through the worker pipeline. - Storybook: `Agents / Personas` Sizes, Expressions and Palettes render the studio character at every size and pose; `Onboarding / Character → Wake Up` plays the wake on the shared engine; `Onboarding / Agent arc` walks 1 → 4 with the hand-offs, and `Arrive From Cloud` plays the arrival (measured: content room 6 → 65px over 320ms, fade to 1.0 by ~560ms, footer travel continuous). - The original author walked the agent → connect → review flow and wake after a real sign-in on staging. - Not done here: the Linux Storybook visual baselines (`tests/storybook-visual/agent-personas.spec.ts`) need re-baselining for the new engine, hero size and naming-step changes. ## Risks - Every avatar's pixels change (new engine, new character) under the unchanged `cap-v1` name. Stacks that rendered avatars on the previous engine keep those PNGs in their cache (`generated-agent-avatars/cap-v1/...`, served immutable) until cleared; only the two pinned staging stacks ever did. - The one-shot handoff to the idle loop is timed from the sequence's authored duration (the engine reports completion by continuing into idle itself); presentation only, nothing in the wizard's state waits on it. - Reduced motion skips the wake and the hand-offs; jsdom is treated the same way, so the wizard tests see the next step's content immediately. - The committed export differs from the studio by one animation (Loop off, leading idle step removed); a re-export without that fix would play a 5.6s idle before the wake. ## Model Used Original feature: Anthropic Claude Fable 5.1 (`claude-fable-5-1`) in Claude Code, with shell, browser, and file tools. The original context window was not recorded. Merge preparation and lifecycle regression fixes: OpenAI GPT-6 in Codex, with reasoning, shell execution, file editing, GitHub CLI, and automated tests. The session does not expose an exact runtime model ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Dotta Co-authored-by: Paperclip Co-authored-by: Claude Fable 5.1 --- .claude/launch.json | 19 +- doc/agent-personas.md | 14 +- package.json | 2 +- packages/shared/src/agent-appearance.test.ts | 4 +- packages/shared/src/cliplab/PROVENANCE.md | 3 +- packages/shared/src/cliplab/character.ts | 2326 +++++++++++++++++ packages/shared/src/cliplab/definition.ts | 21 +- packages/shared/src/cliplab/face-morph.ts | 12 +- packages/shared/src/cliplab/gaze.ts | 2 +- packages/shared/src/cliplab/model.ts | 46 +- packages/shared/src/cliplab/particles.ts | 26 + packages/shared/src/cliplab/renderer.ts | 113 +- packages/shared/src/cliplab/runtime.ts | 16 +- packages/shared/src/cliplab/svg-canvas.ts | 2 +- packages/shared/src/cliplab/svg-path.ts | 2 +- packages/shared/src/cliplab/svg-snapshot.ts | 10 +- scripts/serve-storybook-static.mjs | 2 +- scripts/sync-cliplab-character.mjs | 28 + server/src/__tests__/agent-avatars.test.ts | 2 +- server/src/app.ts | 6 +- server/src/routes/agent-avatars.ts | 13 +- .../assets/cliplab/onboarding.character.json | 2322 ++++++++++++++++ ui/src/components/OnboardingWizard.tsx | 181 +- .../onboarding/OnboardingCharacter.test.tsx | 107 + .../onboarding/OnboardingCharacter.tsx | 190 ++ .../onboarding/onboarding-character.test.ts | 65 + .../onboarding/onboarding-character.ts | 76 + .../onboarding/onboarding-motion.ts | 166 +- .../task-chat/motion-tokens.test.ts | 12 +- ui/src/components/task-chat/motion-tokens.ts | 6 +- ui/src/index.css | 10 +- ui/src/lib/onboarding-motion-tokens.test.ts | 26 + ui/src/lib/onboarding-motion-tokens.ts | 32 + ui/src/motion-tokens.css | 29 + ui/storybook/.storybook/preview.tsx | 8 + .../stories/onboarding-agent-arc.stories.tsx | 37 +- .../stories/onboarding-character.stories.tsx | 44 + ui/vitest.config.ts | 1 + 38 files changed, 5756 insertions(+), 225 deletions(-) create mode 100644 packages/shared/src/cliplab/character.ts create mode 100644 packages/shared/src/cliplab/particles.ts create mode 100644 scripts/sync-cliplab-character.mjs create mode 100644 ui/src/assets/cliplab/onboarding.character.json create mode 100644 ui/src/components/onboarding/OnboardingCharacter.test.tsx create mode 100644 ui/src/components/onboarding/OnboardingCharacter.tsx create mode 100644 ui/src/components/onboarding/onboarding-character.test.ts create mode 100644 ui/src/components/onboarding/onboarding-character.ts create mode 100644 ui/src/lib/onboarding-motion-tokens.test.ts create mode 100644 ui/src/lib/onboarding-motion-tokens.ts create mode 100644 ui/src/motion-tokens.css create mode 100644 ui/storybook/stories/onboarding-character.stories.tsx diff --git a/.claude/launch.json b/.claude/launch.json index 6258bdc8cc..219f5be924 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -3,13 +3,26 @@ "configurations": [ { "name": "paperclip", + "port": 3121, + "url": "http://localhost:3121" + }, + { + "name": "storybook", "runtimeExecutable": "/bin/sh", "runtimeArgs": [ "-c", - "TMPDIR=/tmp pnpm dev" + "PAPERCLIP_STORYBOOK_API_URL=http://localhost:3121 pnpm --filter @paperclipai/ui exec storybook dev -p 6016 -c storybook/.storybook --no-open --ci" ], - "port": 3108, - "autoPort": true + "port": 6016 + }, + { + "name": "cloud-web", + "runtimeExecutable": "/bin/sh", + "runtimeArgs": [ + "-c", + "cd /Users/tonio/Projects/paperclip-cloud/.worktrees/deploy-stack-master/web && npx vite --port 4173 --strictPort" + ], + "port": 4173 } ] } diff --git a/doc/agent-personas.md b/doc/agent-personas.md index 6a4120e739..2308957bc8 100644 --- a/doc/agent-personas.md +++ b/doc/agent-personas.md @@ -3,7 +3,13 @@ Agents have a persisted visual identity independent of prompts and runtime configuration: `{ schemaVersion: 1, characterVersion: "cap-v1", paletteId }`. The cap-v1 library contains 17 permanent palettes and a presentation-only gray -Muted dream palette. New agents get one random assignment; existing rows are +Muted dream palette. The character itself is one ClipLab studio export — +`ui/src/assets/cliplab/onboarding.character.json` (end cap, custom idle, +sleepy-to-wake) — mirrored into the shared package as +`packages/shared/src/cliplab/character.ts` by +`node scripts/sync-cliplab-character.mjs` (`--check` detects drift); every +palette recolours its body. The onboarding hero and every avatar are the same +character on the same engine (ClipLab v0.2.0, see PROVENANCE.md). New agents get one random assignment; existing rows are backfilled with the same ID-based mapping used by legacy clients. Duplicating an agent generates another assignment. Export/import preserves it. @@ -80,11 +86,17 @@ hashes and pixel dimensions; deployment verification fetches every image and che its content type, PNG signature, dimensions, and hash. Dev Storybook still uses the API proxy for cold-cache and regeneration testing. +The onboarding motion values live in `ui/src/motion-tokens.css`, imported by +`ui/src/index.css`. The JavaScript choreography reads these CSS tokens and uses +the same stylesheet for defaults before styles load. Reduced motion removes +transition durations; the connection status hold remains readable. + Focused checks: ```sh pnpm exec vitest run packages/shared/src/agent-appearance.test.ts server/src/__tests__/agent-avatars.test.ts node scripts/sync-agent-palette-tokens.mjs --check +node scripts/sync-cliplab-character.mjs --check pnpm check:token-gates pnpm build-storybook ``` diff --git a/package.json b/package.json index 5532df2622..69fa2dce14 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "release:rollback": "./scripts/rollback-latest.sh", "release:bootstrap-package": "node scripts/bootstrap-npm-package.mjs", "check:tokens": "node scripts/check-forbidden-tokens.mjs", - "check:token-gates": "node scripts/check-token-gates.mjs && node scripts/sync-agent-palette-tokens.mjs --check", + "check:token-gates": "node scripts/check-token-gates.mjs && node scripts/sync-agent-palette-tokens.mjs --check && node scripts/sync-cliplab-character.mjs --check", "check:node-version": "node scripts/check-node-version-policy.mjs", "check:no-git-push": "node scripts/check-no-git-push.mjs", "check:module-boundaries": "node scripts/check-module-boundaries.mjs", diff --git a/packages/shared/src/agent-appearance.test.ts b/packages/shared/src/agent-appearance.test.ts index 36bd71510a..719a280c2b 100644 --- a/packages/shared/src/agent-appearance.test.ts +++ b/packages/shared/src/agent-appearance.test.ts @@ -16,9 +16,11 @@ describe("agent appearance", () => { }); it("renders without a browser and preserves logical-size detail at high density", () => { const appearance = appearanceForPalette("bubblegum-sky"); + // ClipLab v0.2.0 draws an enlarged compact face from 16px; only 12px and + // below (not a logical size) is body-only. const small = renderAgentSvg(appearance, 16, 2); expect(small).toContain('width="32"'); - expect(small).not.toContain('id="agent-face-visible"'); + expect(small).toContain('id="agent-face-visible"'); const eyes = renderAgentSvg(appearance, 24, 2); expect(eyes).toContain('width="48"'); expect(eyes).toContain('id="agent-face-visible"'); diff --git a/packages/shared/src/cliplab/PROVENANCE.md b/packages/shared/src/cliplab/PROVENANCE.md index 3c4964707c..66a7b9b123 100644 --- a/packages/shared/src/cliplab/PROVENANCE.md +++ b/packages/shared/src/cliplab/PROVENANCE.md @@ -1,7 +1,8 @@ # ClipLab cap-v1 Source: https://github.com/tonio-alucema/cliplab -Commit: a050f7246738db1d0763e286862747633293e2af +Release: v0.2.0 (runtime package 0.2.0) +Commit: 987b6db049471df815ebcb88bcd7abb46ed0c34b License: MIT (see LICENSE). Three.js is MIT licensed. Paperclip adaptations: ESM extensions, optional graphics backend for Node SVG diff --git a/packages/shared/src/cliplab/character.ts b/packages/shared/src/cliplab/character.ts new file mode 100644 index 0000000000..f338262f28 --- /dev/null +++ b/packages/shared/src/cliplab/character.ts @@ -0,0 +1,2326 @@ +// Generated from ui/src/assets/cliplab/onboarding.character.json by scripts/sync-cliplab-character.mjs. Do not edit. +import type { Definition } from "./model.js"; + +/** The persona character: Tonio's ClipLab studio export, every palette recolours its body. */ +export const PAPERCLIP_CHARACTER: Definition = { + "version": 1, + "character": { + "id": "character-5128bda1", + "name": "Milo copy", + "shape": "cap", + "color": "#0f57ff", + "color2": "#4deaff", + "gradient": true, + "gradientAngle": 20, + "toon": true, + "trueFront": false, + "lockPosition": true, + "eyeColor": "#172023", + "iris": false, + "elevated": true, + "elevation": 0.115, + "shadow": true, + "motion": 0.45, + "speed": 1, + "blink": true, + "blinkInterval": 6, + "followCursor": false, + "followRotation": false + }, + "expressions": [ + { + "id": "idle", + "name": "Idle", + "description": "A little breath. A quiet smile.", + "beats": [ + { + "id": "idle-1", + "name": "Settle", + "duration": 1.8, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1.12, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.74, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1.6 + } + }, + { + "id": "idle-2", + "name": "Look around", + "duration": 1.4, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0.22, + "gazeY": 0, + "mouthWidth": 1.12, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 9, + "rotationZ": -3, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1.6 + } + }, + { + "id": "idle-3", + "name": "Return", + "duration": 1, + "pose": { + "eye": "dot", + "mouth": "line", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1.12, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 33, + "rightY": 33, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 8, + "rotationY": 0, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1.6 + } + }, + { + "id": "beat-57cf6bfb", + "name": "Look around", + "duration": 1.4, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0.22, + "gazeY": 0, + "mouthWidth": 1.12, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 5, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1.6 + } + } + ] + }, + { + "id": "listening", + "name": "Listening", + "description": "Present, curious, and all ears.", + "beats": [ + { + "id": "listen-1", + "name": "Notice", + "duration": 1.2, + "pose": { + "eye": "wide", + "mouth": "line", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1.06, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": -4, + "rotationY": 0, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "listen-2", + "name": "Lean in", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1.13, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": -7, + "rotationY": 0, + "rotationZ": 6, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "listen-3", + "name": "Nod", + "duration": 1.1, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 5, + "rotationY": 0, + "rotationZ": 3, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + } + ] + }, + { + "id": "thinking", + "name": "Thinking", + "description": "A glance up. Something is taking shape.", + "beats": [ + { + "id": "think-1", + "name": "Wonder", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "oh", + "prop": "question", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0.6, + "gazeY": 0.55, + "mouthWidth": 0.6, + "mouthOpen": 0.23, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": -7, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "think-2", + "name": "Consider", + "duration": 1.6, + "pose": { + "eye": "soft", + "mouth": "wave", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": -0.35, + "gazeY": 0.2, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": -8, + "rotationZ": 4, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "think-3", + "name": "Almost", + "duration": 1.2, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0.25, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": -3, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + } + ] + }, + { + "id": "working", + "name": "Working", + "description": "Small, focused, steady movements.", + "beats": [ + { + "id": "work-1", + "name": "Focus left", + "duration": 1, + "pose": { + "eye": "soft", + "mouth": "line", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": -0.5, + "gazeY": -0.3, + "mouthWidth": 0.65, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 4, + "rotationY": -7, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "work-2", + "name": "Focus right", + "duration": 1, + "pose": { + "eye": "soft", + "mouth": "line", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0.5, + "gazeY": -0.3, + "mouthWidth": 0.65, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 4, + "rotationY": 7, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "work-3", + "name": "Check", + "duration": 1.4, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": -3, + "rotationY": 0, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + } + ] + }, + { + "id": "happy", + "name": "Happy", + "description": "A small open smile says it all.", + "beats": [ + { + "id": "happy-1", + "name": "Light up", + "duration": 0.9, + "pose": { + "eye": "dot", + "mouth": "open", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": -5, + "squash": 1.025, + "tongue": true, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "happy-2", + "name": "Delight", + "duration": 1.3, + "pose": { + "eye": "closed", + "mouth": "open", + "prop": "sparkle", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.75, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": 5, + "squash": 0.97, + "tongue": true, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "happy-3", + "name": "Glow", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "open", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.45, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": -2, + "squash": 1, + "tongue": true, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + } + ] + }, + { + "id": "confused", + "name": "Confused", + "description": "A little uneven. A second look.", + "beats": [ + { + "id": "confused-1", + "name": "Wait", + "duration": 1.2, + "pose": { + "eye": "soft", + "mouth": "wave", + "prop": "question", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 0.7, + "rightScale": 1.1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": 10, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "confused-2", + "name": "Really?", + "duration": 1.4, + "pose": { + "eye": "dot", + "mouth": "oh", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1.1, + "rightScale": 0.7, + "gazeX": -0.2, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.25, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": -9, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "confused-3", + "name": "Hmm", + "duration": 1.3, + "pose": { + "eye": "soft", + "mouth": "frown", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 0.75, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": 4, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + } + ] + }, + { + "id": "sleepy", + "name": "Sleepy", + "description": "Slow breathing, drifting sleep marks.", + "beats": [ + { + "id": "sleep-1", + "name": "Doze", + "duration": 2, + "pose": { + "eye": "closed", + "mouth": "sleep", + "prop": "zzz", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 4, + "rotationY": 0, + "rotationZ": -6, + "squash": 0.97, + "tongue": false, + "teeth": false, + "drool": true, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "sleep-2", + "name": "Breathe in", + "duration": 2, + "pose": { + "eye": "closed", + "mouth": "oh", + "prop": "zzz", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 0.65, + "mouthOpen": 0.25, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": -3, + "squash": 1.025, + "tongue": false, + "teeth": false, + "drool": true, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "sleep-3", + "name": "Breathe out", + "duration": 2, + "pose": { + "eye": "closed", + "mouth": "sleep", + "prop": "zzz", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": -7, + "squash": 0.98, + "tongue": false, + "teeth": false, + "drool": true, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + } + ] + }, + { + "id": "playful", + "name": "Playful", + "description": "A wink, a grin, a little mischief.", + "beats": [ + { + "id": "play-1", + "name": "Peek", + "duration": 1.1, + "pose": { + "eye": "dot", + "mouth": "open", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": -10, + "rotationZ": -8, + "squash": 1, + "tongue": true, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "play-2", + "name": "Wink", + "duration": 1.2, + "pose": { + "eye": "wink", + "mouth": "open", + "prop": "sparkle", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.7, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": 9, + "squash": 1, + "tongue": true, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "play-3", + "name": "Grin", + "duration": 1.3, + "pose": { + "eye": "dot", + "mouth": "open", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.45, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": -3, + "squash": 1, + "tongue": false, + "teeth": true, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + } + ] + }, + { + "id": "loading", + "name": "Loading", + "description": "A slow start, one gradient turn, then a half-second rest.", + "poseExpressionId": "working", + "beats": [ + { + "id": "loading-rotate", + "name": "Rotate", + "duration": 2, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + }, + "gradientAction": "rotate", + "gradientTurns": 4 + } + ] + }, + { + "id": "expression-7287c053", + "name": "Idle side to side 01", + "description": "", + "beats": [ + { + "id": "beat-3b4f9de9", + "name": "Begin", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-9516b036", + "name": "Beat 4", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 11, + "rotationY": 0, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-c3858187", + "name": "turn", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "line", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 4, + "rotationY": 39, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-41ccd5c0", + "name": "Retur", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 4, + "rotationY": 0, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + } + ] + }, + { + "id": "expression-f9a89083", + "name": "Idle side to side 02", + "description": "", + "beats": [ + { + "id": "beat-f9434a07", + "name": "Begin", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 48, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-68deb476", + "name": "Beat 4", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 11, + "rotationY": 38, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-5c04098e", + "name": "Beat 4", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": -5, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-9546ee11", + "name": "turn", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "line", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 4, + "rotationY": 26, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + } + ] + }, + { + "id": "expression-b20a0ef7", + "name": "Idle side to side 03", + "description": "", + "beats": [ + { + "id": "beat-1b2247b8", + "name": "Begin", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 20, + "rotationY": 38, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-1df49105", + "name": "Beat 4", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": -8, + "rotationY": 44, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-2b9fa4f3", + "name": "turn", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "line", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 4, + "rotationY": 26, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-8bb4cc3c", + "name": "Beat 4", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 12, + "rotationY": -4, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + } + ] + }, + { + "id": "expression-229a39b6", + "name": "Sleepy to wake", + "description": "Slow breathing, drifting sleep marks.", + "beats": [ + { + "id": "beat-3460c6a3", + "name": "Doze", + "duration": 1, + "pose": { + "eye": "closed", + "mouth": "sleep", + "prop": "zzz", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1.2, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 4, + "rotationY": 0, + "rotationZ": -6, + "squash": 0.97, + "tongue": false, + "teeth": false, + "drool": true, + "cheeks": false, + "tears": false, + "mouthStroke": 1.2 + } + }, + { + "id": "beat-8b5d3238", + "name": "Breathe in", + "duration": 0.5, + "pose": { + "eye": "wide", + "mouth": "oh", + "prop": "question", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 0.65, + "mouthOpen": 1, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": -3, + "rotationY": 0, + "rotationZ": -3, + "squash": 1.025, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-c35e2a7e", + "name": "Breathe in", + "duration": 1, + "pose": { + "eye": "wide", + "mouth": "oh", + "prop": "question", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 0.65, + "mouthOpen": 1, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 11, + "rotationY": 30, + "rotationZ": -3, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-787f488e", + "name": "Wink", + "duration": 1.2, + "pose": { + "eye": "wink", + "mouth": "open", + "prop": "sparkle", + "propSize": 1.45, + "propCount": 3, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 0.59, + "mouthOpen": 0.71, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": -11, + "rotationY": 9, + "rotationZ": 11, + "squash": 1.05, + "tongue": true, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 0.85 + } + }, + { + "id": "beat-2cafeefa", + "name": "Settle", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1.12, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.74, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1.6 + } + } + ] + } + ], + "animations": [ + { + "id": "idle", + "name": "Idle", + "loop": true, + "steps": [ + { + "id": "idle-step-1", + "expressionId": "idle", + "duration": 5 + } + ] + }, + { + "id": "listening", + "name": "Listening", + "loop": true, + "steps": [ + { + "id": "listening-step-1", + "expressionId": "listening", + "duration": 3.8 + }, + { + "id": "listening-step-2", + "expressionId": "idle", + "duration": 1.6 + } + ] + }, + { + "id": "thinking", + "name": "Thinking", + "loop": true, + "steps": [ + { + "id": "thinking-step-1", + "expressionId": "thinking", + "duration": 4.3 + }, + { + "id": "thinking-step-2", + "expressionId": "working", + "duration": 1.8 + } + ] + }, + { + "id": "working", + "name": "Working", + "loop": true, + "steps": [ + { + "id": "working-step-1", + "expressionId": "working", + "duration": 6.8 + }, + { + "id": "working-step-2", + "expressionId": "thinking", + "duration": 2.2 + } + ] + }, + { + "id": "happy", + "name": "Success", + "loop": true, + "steps": [ + { + "id": "happy-step-1", + "expressionId": "happy", + "duration": 3.7 + }, + { + "id": "happy-step-2", + "expressionId": "playful", + "duration": 2.4 + }, + { + "id": "happy-step-3", + "expressionId": "idle", + "duration": 1.8 + } + ] + }, + { + "id": "confused", + "name": "Confused", + "loop": true, + "steps": [ + { + "id": "confused-step-1", + "expressionId": "confused", + "duration": 3.9 + }, + { + "id": "confused-step-2", + "expressionId": "thinking", + "duration": 2.1 + } + ] + }, + { + "id": "sleepy", + "name": "Sleepy", + "loop": true, + "steps": [ + { + "id": "sleepy-step-1", + "expressionId": "sleepy", + "duration": 6 + } + ] + }, + { + "id": "playful", + "name": "Playful", + "loop": true, + "steps": [ + { + "id": "playful-step-1", + "expressionId": "playful", + "duration": 3.6 + }, + { + "id": "playful-step-2", + "expressionId": "happy", + "duration": 2.2 + } + ] + }, + { + "id": "animation-078668cd", + "name": "New animation", + "loop": true, + "steps": [ + { + "id": "step-fde801f7", + "expressionId": "working", + "duration": 3.4 + } + ] + }, + { + "id": "loading", + "name": "Loading", + "loop": true, + "steps": [ + { + "id": "step-5fd1d737", + "expressionId": "loading", + "duration": 2.5 + } + ] + }, + { + "id": "animation-0dd1e312", + "name": "Listening 2", + "loop": true, + "steps": [ + { + "id": "step-31c18790", + "expressionId": "idle", + "duration": 5 + }, + { + "id": "step-48e70fa6", + "expressionId": "listening", + "duration": 3.8000000000000003 + } + ] + }, + { + "id": "animation-c1df7072", + "name": "idle side to side 01", + "loop": true, + "steps": [ + { + "id": "step-dd427653", + "expressionId": "expression-7287c053", + "duration": 6 + } + ] + }, + { + "id": "animation-0efb6f80", + "name": "idle side to side 02", + "loop": true, + "steps": [ + { + "id": "step-a000b372", + "expressionId": "expression-f9a89083", + "duration": 6 + } + ] + }, + { + "id": "animation-9386f24d", + "name": "idle side to side 03", + "loop": true, + "steps": [ + { + "id": "step-04145e45", + "expressionId": "expression-b20a0ef7", + "duration": 6 + } + ] + }, + { + "id": "animation-2f02a915", + "name": "sleepy to wake", + "loop": false, + "steps": [ + { + "id": "step-7fa9addf", + "expressionId": "expression-229a39b6", + "duration": 5.2 + } + ] + } + ] +} as unknown as Definition; diff --git a/packages/shared/src/cliplab/definition.ts b/packages/shared/src/cliplab/definition.ts index 73914402b3..0c6f0789c6 100644 --- a/packages/shared/src/cliplab/definition.ts +++ b/packages/shared/src/cliplab/definition.ts @@ -1,18 +1,27 @@ import type { AgentAppearance, CharacterPaletteId, CharacterState } from "../agent-appearance.js"; -import { defaultProject, definitionOf, sampleDefinition, BASE_POSE, type Definition, type Sample } from "./model.js"; +import { definitionOf, sampleDefinition, type Definition, type Sample } from "./model.js"; import { CAP_V1_COLORS } from "./palette-tokens.js"; +import { PAPERCLIP_CHARACTER } from "./character.js"; +/** + * One character, every palette: the studio export's body, expressions and + * animations, recoloured with the agent's palette (or the presentation-only + * gray). Body and eye following are on for live placements; static + * portraits override them per render. + */ export function characterDefinition(appearance: AgentAppearance, muted = false): Definition { - const project = defaultProject(); const colors = CAP_V1_COLORS[(muted ? "muted-dream" : appearance.paletteId) as CharacterPaletteId]; - const character = { ...project.characters[1], id: "paperclip-cap-v1", name: "Agent", color: colors.a, color2: colors.b, - gradientAngle: 0, iris: false, toon: true, shadow: false, followCursor: true, followRotation: true }; - const definition = definitionOf(project, character); + const character = { ...PAPERCLIP_CHARACTER.character, id: "paperclip-cap-v1", name: "Agent", color: colors.a, color2: colors.b, followCursor: true, followRotation: true }; + const definition = definitionOf({ version: 1, name: "Paperclip", characters: [character], expressions: PAPERCLIP_CHARACTER.expressions, animations: PAPERCLIP_CHARACTER.animations }, character); for (const animation of definition.animations) if (animation.id === "happy") animation.loop = false; return definition; } export function animationId(state: CharacterState) { return state === "success" ? "happy" : state === "rest" ? "idle" : state; } +/** A fixed sample per state for still portraits; rest is the idle expression's first beat, held. */ export function characterStill(definition: Definition, state: CharacterState): Sample { - if (state === "rest") return { pose: { ...BASE_POSE }, blink: 0, bob: 0, breathe: 0, expressionId: "idle", beatIndex: 0, stepIndex: 0 }; + if (state === "rest") { + const idle = definition.expressions.find((e) => e.id === "idle") ?? definition.expressions[0]!; + return { pose: { ...idle.beats[0]!.pose }, blink: 0, bob: 0, breathe: 0, expressionId: idle.id, beatIndex: 0, stepIndex: 0, faceLayers: undefined }; + } return sampleDefinition(definition, animationId(state), 0.6); } diff --git a/packages/shared/src/cliplab/face-morph.ts b/packages/shared/src/cliplab/face-morph.ts index ed485fa245..b46ea3c50e 100644 --- a/packages/shared/src/cliplab/face-morph.ts +++ b/packages/shared/src/cliplab/face-morph.ts @@ -1,4 +1,4 @@ -// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE. +// Vendored from ClipLab 987b6db0 (v0.2.0); see PROVENANCE.md and LICENSE. import type { Detail, FaceLayer, Pose } from './model.js' import { irisOffset, type Gaze } from './gaze.js' @@ -183,6 +183,12 @@ export function drawMorphMouth(ctx: CanvasRenderingContext2D, pose: Pose, layers if (tongue > 0) { ctx.globalAlpha = tongue; ctx.fillStyle = '#f37b83'; ctx.beginPath(); ctx.ellipse(10, h * .65, w * .65, h * .34, -.1, 0, TAU); ctx.fill() } if (teeth > 0) { ctx.globalAlpha = teeth; ctx.fillStyle = '#fffef8'; ctx.beginPath(); ctx.roundRect(-w * .76, -23, w * 1.52, 30, 12); ctx.fill() } ctx.restore() + // Match the still renderer: interior details must not paint over the inner + // half of the lip stroke. Clip to the blended outline during shape morphs. + if (tongue > 0 || teeth > 0) { + ctx.save(); trace(ctx, outline); ctx.clip(); trace(ctx, opening) + ctx.strokeStyle = ink; ctx.lineWidth = 17 * pose.mouthStroke; ctx.lineJoin = 'round'; ctx.stroke(); ctx.restore() + } const out = traitWeight(layers, t => t.mouth === 'tongue-out') if (out > 0) { const w = 61 * pose.mouthWidth @@ -192,14 +198,14 @@ export function drawMorphMouth(ctx: CanvasRenderingContext2D, pose: Pose, layers if (drool > 0) drawDrool(ctx, droolAnchor(outline, w), drool) } -export function drawMorphBrow(ctx: CanvasRenderingContext2D, layers: FaceLayer[], r: number, side: number, ink: string) { +export function drawMorphBrow(ctx: CanvasRenderingContext2D, layers: FaceLayer[], r: number, side: number, ink: string, stroke = 1, length = 1) { if (!layers.some(layer => layer.traits.brows !== 'none')) return const shapes = layers.map(({ traits }) => { if (traits.brows === 'none') return contour([pt(0, 0)]) const path = traits.brows === 'raised' ? arc(0, 10, r * .85, r * .85, Math.PI * 1.15, Math.PI * 1.85) : traits.brows === 'worried' ? quad(pt(side * r, 2), pt(-side * r * .1, 10), pt(-side * r * .75, -16)) : [pt(side * r, -10), pt(-side * r * .75, 8)] - return contour(ribbon(path, 12)) + return contour(ribbon(path.map(point => ({ ...point, x: point.x * length })), 12 * stroke)) }) ctx.fillStyle = ink; trace(ctx, blendContours(shapes, layers)); ctx.fill() } diff --git a/packages/shared/src/cliplab/gaze.ts b/packages/shared/src/cliplab/gaze.ts index 852055d2c9..66e58f5b47 100644 --- a/packages/shared/src/cliplab/gaze.ts +++ b/packages/shared/src/cliplab/gaze.ts @@ -1,4 +1,4 @@ -// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE. +// Vendored from ClipLab 987b6db0 (v0.2.0); see PROVENANCE.md and LICENSE. export interface Gaze { x: number; y: number } export interface PointerLook extends Gaze { weight: number } export interface EyeGazes { left: Gaze; right: Gaze } diff --git a/packages/shared/src/cliplab/model.ts b/packages/shared/src/cliplab/model.ts index 75572b3dbe..8dd02d7ada 100644 --- a/packages/shared/src/cliplab/model.ts +++ b/packages/shared/src/cliplab/model.ts @@ -1,4 +1,4 @@ -// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE. +// Vendored from ClipLab 987b6db0 (v0.2.0); see PROVENANCE.md and LICENSE. export type Shape = 'capsule' | 'cap' | 'sphere' export type Eye = 'dot' | 'soft' | 'closed' | 'wink' | 'star' | 'heart' | 'squint' | 'wide' | 'arc-up' | 'arc-down' | 'half-lidded' | 'pupil' export type Mouth = 'smile' | 'open' | 'line' | 'frown' | 'oh' | 'wave' | 'sleep' | 'grin' | 'cry' | 'u-smile' | 'kiss' | 'tongue-out' @@ -6,8 +6,8 @@ export type Prop = 'none' | 'zzz' | 'sparkle' | 'heart' | 'question' | 'sweat' | export type Detail = 'body' | 'eyes' | 'full' export interface Pose { - eye: Eye; mouth: Mouth; prop: Prop - faceSet: 'set-1' | 'set-2'; brows: 'none' | 'raised' | 'worried' | 'angry'; blush: number + eye: Eye; mouth: Mouth; prop: Prop; propSize: number; propCount: number; propOutward: number + faceSet: 'set-1' | 'set-2'; brows: 'none' | 'raised' | 'worried' | 'angry'; browStroke: number; browLength: number; blush: number eyeSize: number; eyeHeight: number; spacing: number; eyeTilt: number leftScale: number; rightScale: number; gazeX: number; gazeY: number leftX: number; rightX: number; leftY: number; rightY: number; leftRotation: number; rightRotation: number @@ -15,7 +15,8 @@ export interface Pose { rotationX: number; rotationY: number; rotationZ: number; squash: number tongue: boolean; teeth: boolean; drool: boolean; cheeks: boolean; tears: boolean; mouthStroke: number } -export interface Beat { id: string; name: string; duration: number; pose: Pose; gradientAction?: 'rotate' | 'hold' } +export interface Beat { id: string; name: string; duration: number; pose: Pose; gradientAction?: 'rotate' | 'hold'; gradientTurns?: number } +export interface FavoriteBeat { id: string; sourceBeatId: string; beat: Beat } export interface Expression { id: string; name: string; description: string; beats: Beat[]; poseExpressionId?: string } export interface Step { id: string; expressionId: string; duration: number } export interface Animation { id: string; name: string; steps: Step[]; loop: boolean } @@ -26,7 +27,19 @@ export interface Character { motion: number; speed: number; blink: boolean; blinkInterval: number; followCursor: boolean; followRotation: boolean } export interface Definition { version: 1; character: Character; expressions: Expression[]; animations: Animation[] } -export interface Project { version: 1; name: string; characters: Character[]; expressions: Expression[]; animations: Animation[]; defaultsRevision?: number } +export interface Project { version: 1; name: string; characters: Character[]; expressions: Expression[]; animations: Animation[]; defaultsRevision?: number; favoriteBeats?: FavoriteBeat[] } +/** Remove references too, keeping every animation playable and the project importable. */ +export function removeExpression(project: Project, id: string) { + if (project.expressions.length <= 1 || !project.expressions.some(e => e.id === id)) return false + project.expressions = project.expressions.filter(e => e.id !== id) + const fallback = project.expressions[0]! + for (const expression of project.expressions) if (expression.poseExpressionId === id) delete expression.poseExpressionId + for (const animation of project.animations) { + animation.steps = animation.steps.filter(step => step.expressionId !== id) + if (!animation.steps.length) animation.steps.push({ id: uid('step'), expressionId: fallback.id, duration: expressionDuration(fallback) }) + } + return true +} export type FaceTraits = Pick export interface FaceLayer { traits: FaceTraits; weight: number } export interface Sample { pose: Pose; blink: number; bob: number; breathe: number; expressionId: string; beatIndex: number; stepIndex: number; effectPhase?: number; propAmount?: number; tearAmount?: number; gradientRotation?: number; gradientMix?: number; faceLayers?: FaceLayer[] } @@ -42,11 +55,11 @@ export const PALETTES = [ ['#f0ce58', '#fff1a5'], ['#ee98b2', '#ffd2da'], ['#72b6de', '#b9e4f5'] ] -export function detailAt(size: number): Detail { return size <= 16 ? 'body' : size <= 24 ? 'eyes' : 'full' } +export function detailAt(size: number): Detail { return size <= 12 ? 'body' : 'full' } export function uid(prefix = 'item') { return `${prefix}-${crypto.randomUUID().slice(0, 8)}` } export function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T } export const BASE_POSE: Pose = { - eye: 'dot', mouth: 'smile', prop: 'none', faceSet: 'set-1', brows: 'none', blush: 0, eyeSize: 1, eyeHeight: 1, spacing: 1, eyeTilt: 0, + eye: 'dot', mouth: 'smile', prop: 'none', propSize: 1, propCount: 0, propOutward: 1, faceSet: 'set-1', brows: 'none', browStroke: 1, browLength: 1, blush: 0, eyeSize: 1, eyeHeight: 1, spacing: 1, eyeTilt: 0, leftScale: 1, rightScale: 1, gazeX: 0, gazeY: 0, mouthWidth: 1, mouthOpen: .5, leftX: 0, rightX: 0, leftY: 0, rightY: 0, leftRotation: 0, rightRotation: 0, faceScale: .75, faceY: 0, rotationX: 0, rotationY: 0, rotationZ: 0, squash: 1, @@ -96,7 +109,7 @@ export function defaultExpressions(): Expression[] { export function defaultProject(): Project { const base: Character = { id: 'milo', name: 'Milo', shape: 'capsule', color: '#ff986d', color2: '#ffd092', gradient: true, - gradientAngle: 20, toon: true, trueFront: false, lockPosition: false, eyeColor: '#080909', iris: false, elevated: false, elevation: .065, + gradientAngle: -24, toon: true, trueFront: false, lockPosition: false, eyeColor: '#080909', iris: false, elevated: false, elevation: .065, shadow: true, motion: .45, speed: 1, blink: true, blinkInterval: 4.2, followCursor: false, followRotation: false } const expressions = defaultExpressions() @@ -178,6 +191,7 @@ export function mixPose(a: Pose, b: Pose, t: number): Pose { const smooth = (t: number) => { const v = Math.max(0, Math.min(1, t)); return v * v * (3 - 2 * v) } function featureAmount(a: unknown, b: unknown, blend: number) { return a === b ? 1 : blend < .5 ? 1 - smooth(blend * 2) : smooth((blend - .5) * 2) } const mod = (t: number, d: number) => ((t % d) + d) % d +export const gradientTurns = (beat: Beat) => Number.isFinite(beat.gradientTurns) ? Math.max(1, Math.min(8, Math.round(beat.gradientTurns!))) : 1 export function sampleExpression(expression: Expression, time: number, expressions: Expression[] = [], visited = new Set()): { pose: Pose; beatIndex: number; propAmount: number; tearAmount: number; gradientRotation?: number; gradientMix?: number; faceLayers: FaceLayer[] } { const duration = expressionDuration(expression) let t = mod(time, duration) @@ -195,9 +209,9 @@ export function sampleExpression(expression: Expression, time: number, expressio const turnEased = (1 - Math.cos(Math.PI * turn)) / 2 const source = expressions.find(e => e.id === expression.poseExpressionId && !visited.has(e.id) && e.id !== expression.id) const face = source ? sampleExpression(source, mod(time, duration) / duration * expressionDuration(source), expressions, new Set([...visited, expression.id])) : undefined - return { pose: face?.pose ?? mixPose(previous.pose, beat.pose, eased), faceLayers: face?.faceLayers ?? mixFaceLayers(faceLayers(previous.pose), faceLayers(beat.pose), eased), beatIndex: i, propAmount: face?.propAmount ?? featureAmount(previous.pose.prop, beat.pose.prop, eased), tearAmount: face?.tearAmount ?? featureAmount(previous.pose.tears, beat.pose.tears, eased), ...(gradient ? { gradientRotation: gradientStart + (beat.gradientAction === 'rotate' ? 360 * turnEased : 0), gradientMix: 1 } : {}) } + return { pose: face?.pose ?? mixPose(previous.pose, beat.pose, eased), faceLayers: face?.faceLayers ?? mixFaceLayers(faceLayers(previous.pose), faceLayers(beat.pose), eased), beatIndex: i, propAmount: face?.propAmount ?? featureAmount(previous.pose.prop, beat.pose.prop, eased), tearAmount: face?.tearAmount ?? featureAmount(previous.pose.tears, beat.pose.tears, eased), ...(gradient ? { gradientRotation: gradientStart + (beat.gradientAction === 'rotate' ? 360 * gradientTurns(beat) * turnEased : 0), gradientMix: 1 } : {}) } } - if (beat.gradientAction === 'rotate') gradientStart += 360 + if (beat.gradientAction === 'rotate') gradientStart += 360 * gradientTurns(beat) t -= beatDuration } return { pose: p(), faceLayers: faceLayers(p()), beatIndex: 0, propAmount: 1, tearAmount: 1 } @@ -266,13 +280,17 @@ function choice(v: unknown, options: T[], fallback: T) { retur function boolean(v: unknown, fallback: boolean) { return typeof v === 'boolean' ? v : fallback } function parsePose(value: unknown): Pose { const v = obj(value), pose = { ...BASE_POSE } - const ranges: Record = { blush: [0, 1], mouthStroke: [.5, 2.2], leftX: [-60, 60], rightX: [-60, 60], leftY: [-60, 60], rightY: [-60, 60], leftRotation: [-90, 90], rightRotation: [-90, 90], eyeSize: [.35, 2], eyeHeight: [.15, 2], spacing: [.5, 1.6], eyeTilt: [-45, 45], leftScale: [.3, 1.8], rightScale: [.3, 1.8], gazeX: [-1, 1], gazeY: [-1, 1], mouthWidth: [.3, 1.7], mouthOpen: [.1, 1], faceScale: [.6, 1.4], faceY: [-.3, .3], rotationX: [-180, 180], rotationY: [-180, 180], rotationZ: [-180, 180], squash: [.8, 1.2] } + const ranges: Record = { propSize: [.5, 2], propCount: [0, 6], propOutward: [0, 3], browStroke: [.3, 2.5], browLength: [.5, 2], blush: [0, 1], mouthStroke: [.5, 2.2], leftX: [-60, 60], rightX: [-60, 60], leftY: [-60, 60], rightY: [-60, 60], leftRotation: [-90, 90], rightRotation: [-90, 90], eyeSize: [.35, 2], eyeHeight: [.15, 2], spacing: [.5, 1.6], eyeTilt: [-45, 45], leftScale: [.3, 1.8], rightScale: [.3, 1.8], gazeX: [-1, 1], gazeY: [-1, 1], mouthWidth: [.3, 1.7], mouthOpen: [.1, 1], faceScale: [.6, 1.4], faceY: [-.3, .3], rotationX: [-180, 180], rotationY: [-180, 180], rotationZ: [-180, 180], squash: [.8, 1.2] } for (const key of numericKeys) { const [lo, hi] = ranges[key]!; (pose as unknown as Record)[key] = num(v[key], BASE_POSE[key] as number, lo, hi) } return { ...pose, faceSet: choice(v.faceSet, ['set-1', 'set-2'], 'set-1'), brows: choice(v.brows, ['none', 'raised', 'worried', 'angry'], 'none'), eye: choice(v.eye, EYES, 'dot'), mouth: choice(v.mouth, MOUTHS, 'smile'), prop: choice(v.prop, PROPS, 'none'), tongue: boolean(v.tongue, false), teeth: boolean(v.teeth, false), drool: boolean(v.drool, false), cheeks: boolean(v.cheeks, false), tears: boolean(v.tears, false) } } export function parseCharacter(value: unknown): Character { const v = obj(value), d = defaultProject().characters[0]! - return { ...d, id: str(v.id, uid('character')), name: str(v.name, 'Character'), shape: choice(v.shape, ['capsule', 'cap', 'sphere'], 'capsule'), color: color(v.color, d.color), color2: color(v.color2, d.color2), gradient: boolean(v.gradient, true), gradientAngle: num(v.gradientAngle, 20, -180, 180), toon: boolean(v.toon, d.toon), trueFront: boolean(v.trueFront, false), lockPosition: boolean(v.lockPosition, false), eyeColor: color(v.eyeColor, d.eyeColor), iris: boolean(v.iris, false), elevated: boolean(v.elevated, false), elevation: num(v.elevation, .065, .005, .2), shadow: boolean(v.shadow, true), motion: num(v.motion, .45, 0, 1), speed: num(v.speed, 1, .25, 3), blink: boolean(v.blink, true), blinkInterval: num(v.blinkInterval, 4.2, 1, 12), followCursor: boolean(v.followCursor, false), followRotation: boolean(v.followRotation, false) } + return { ...d, id: str(v.id, uid('character')), name: str(v.name, 'Character'), shape: choice(v.shape, ['capsule', 'cap', 'sphere'], 'capsule'), color: color(v.color, d.color), color2: color(v.color2, d.color2), gradient: boolean(v.gradient, true), gradientAngle: num(v.gradientAngle, -24, -180, 180), toon: boolean(v.toon, d.toon), trueFront: boolean(v.trueFront, false), lockPosition: boolean(v.lockPosition, false), eyeColor: color(v.eyeColor, d.eyeColor), iris: boolean(v.iris, false), elevated: boolean(v.elevated, false), elevation: num(v.elevation, .065, .005, .2), shadow: boolean(v.shadow, true), motion: num(v.motion, .45, 0, 1), speed: num(v.speed, 1, .25, 3), blink: boolean(v.blink, true), blinkInterval: num(v.blinkInterval, 4.2, 1, 12), followCursor: boolean(v.followCursor, false), followRotation: boolean(v.followRotation, false) } +} +function parseBeat(item: unknown, index: number): Beat { + const b = obj(item) + return { id: str(b.id, `beat-${index}`), name: str(b.name, `Beat ${index + 1}`), duration: num(b.duration, 1.5, .2, 15), pose: parsePose(b.pose), ...(b.gradientAction === 'rotate' || b.gradientAction === 'hold' ? { gradientAction: b.gradientAction, ...(b.gradientTurns !== undefined ? { gradientTurns: Math.round(num(b.gradientTurns, 1, 1, 8)) } : {}) } : {}) } } export function parseProject(value: unknown): Project { const v = obj(value) @@ -282,7 +300,7 @@ export function parseProject(value: unknown): Project { const expressions = v.expressions.map((item, index): Expression => { const e = obj(item) if (!Array.isArray(e.beats) || !e.beats.length || e.beats.length > 32) throw new Error('Each expression needs 1–32 beats.') - return { id: str(e.id, `expression-${index}`), name: str(e.name, 'Expression'), description: str(e.description, '', 160), ...(typeof e.poseExpressionId === 'string' && e.poseExpressionId ? { poseExpressionId: e.poseExpressionId } : {}), beats: e.beats.map((item, i) => { const b = obj(item); return { id: str(b.id, `beat-${i}`), name: str(b.name, `Beat ${i + 1}`), duration: num(b.duration, 1.5, .2, 15), pose: parsePose(b.pose), ...(b.gradientAction === 'rotate' || b.gradientAction === 'hold' ? { gradientAction: b.gradientAction } : {}) } }) } + return { id: str(e.id, `expression-${index}`), name: str(e.name, 'Expression'), description: str(e.description, '', 160), ...(typeof e.poseExpressionId === 'string' && e.poseExpressionId ? { poseExpressionId: e.poseExpressionId } : {}), beats: e.beats.map(parseBeat) } }) if (new Set(expressions.map(e => e.id)).size !== expressions.length) throw new Error('Expression names in the file must have unique IDs.') for (const expression of expressions) { @@ -302,7 +320,7 @@ export function parseProject(value: unknown): Project { }) const characters = charactersRaw.map(parseCharacter) if (new Set(characters.map(c => c.id)).size !== characters.length || new Set(animations.map(a => a.id)).size !== animations.length) throw new Error('Characters and animations need unique IDs.') - return { version: 1, name: str(v.name, 'My character studio'), characters, expressions, animations, ...(typeof v.defaultsRevision === 'number' ? { defaultsRevision: num(v.defaultsRevision, 2, 0, 1000) } : {}) } + return { version: 1, name: str(v.name, 'My character studio'), characters, expressions, animations, ...(Array.isArray(v.favoriteBeats) ? { favoriteBeats: v.favoriteBeats.slice(0, 200).map((item, i) => { const f = obj(item); return { id: str(f.id, `favorite-${i}`), sourceBeatId: str(f.sourceBeatId, ''), beat: parseBeat(f.beat, i) } }) } : {}), ...(typeof v.defaultsRevision === 'number' ? { defaultsRevision: num(v.defaultsRevision, 2, 0, 1000) } : {}) } } /** Upgrade only untouched studio defaults, once; portable definitions stay literal. */ diff --git a/packages/shared/src/cliplab/particles.ts b/packages/shared/src/cliplab/particles.ts new file mode 100644 index 0000000000..8ee5c63d1b --- /dev/null +++ b/packages/shared/src/cliplab/particles.ts @@ -0,0 +1,26 @@ +// Vendored from ClipLab 987b6db0 (v0.2.0); see PROVENANCE.md and LICENSE. +import type { Pose } from './model.js' + +export type ParticleSettings = Pick +export const defaultParticleCount = (prop: Pose['prop']) => ['zzz', 'sparkle', 'heart'].includes(prop) ? 3 : prop === 'none' ? 0 : 1 +export const particleCount = (prop: Pose['prop'], count = 0) => prop === 'none' ? 0 : count > 0 ? Math.max(1, Math.min(6, Math.round(count))) : defaultParticleCount(prop) +export function particleLayout(prop: Pose['prop'], phase?: number, settings: Partial = {}) { + const count = particleCount(prop, settings.propCount) + const size = settings.propSize ?? 1, outward = settings.propOutward ?? 1 + // A fixed padded canvas for the entire cycle prevents large/outward particles + // clipping against the texture edge or changing size as the padding changes. + const radius = prop === 'crown' ? 100 : prop === 'question' ? 105 : prop === 'sweat' ? 80 : 48 + const extent = Math.max(256, 2 * (110 + outward * 90 + radius * size)) + const particles = Array.from({ length: count }, (_, i) => { + const index = count > 1 ? i * 2 / (count - 1) : 0 + const p = (((phase ?? .34) + i * .29) % 1 + 1) % 1 + const smooth = (x: number) => { const t = Math.max(0, Math.min(1, x)); return t * t * (3 - 2 * t) } + const ease = p * p + const x = count === 1 ? 128 : (prop === 'zzz' ? 45 + index * 64 : 51 + index * 69) + const y = count === 1 ? 132 : (prop === 'zzz' ? 204 - index * 58 : 198 - index * 60) + return { index, x: x + ease * 36 * outward, y: y - ease * (count === 1 ? 26 : prop === 'zzz' ? 54 : 38) * outward, + scale: size * (phase === undefined ? 1 : .62 + .38 * smooth(p / .38)), + alpha: phase === undefined ? 1 : smooth(p / .18) * (1 - smooth((p - .68) / .32)) } + }) + return { extent, particles } +} diff --git a/packages/shared/src/cliplab/renderer.ts b/packages/shared/src/cliplab/renderer.ts index bd6cb6fd6b..47a1154009 100644 --- a/packages/shared/src/cliplab/renderer.ts +++ b/packages/shared/src/cliplab/renderer.ts @@ -1,6 +1,9 @@ -// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE. +// ClipLab renderer adapted for Paperclip (optional graphics backend for Node SVG snapshots, +// supersampled live textures, character framing). Geometry and face code remain upstream v0.2.0. +// Vendored from ClipLab 987b6db0 (v0.2.0); see PROVENANCE.md and LICENSE. import * as THREE from 'three' -import { BASE_POSE, detailAt, type Character, type Detail, type FaceLayer, type Pose, type Sample, type Shape } from './model.js' +import { particleLayout, type ParticleSettings } from './particles.js' +import { BASE_POSE, detailAt, faceLayers, type Character, type Detail, type FaceLayer, type Pose, type Sample, type Shape } from './model.js' import { clampGaze, gazeAtPoint, irisOffset, localGaze, type EyeGazes, type Gaze, type PointerLook } from './gaze.js' import { drawMorphBrow, drawMorphEye, drawMorphMouth, drawDrool, droolAnchor, mouthGeometry, traitWeight } from './face-morph.js' @@ -10,28 +13,31 @@ export interface RenderOptions { pointerLook?: PointerLook; eyeGazes?: EyeGazes; reducedMotion?: boolean } /** Cursor pitch owns the vertical look direction, independent of the resting tilt. */ -export function characterRotation(character: Pick, pose: Pick, rotation = { x: -5, y: -12, z: -7 }, cursor: Gaze = { x: 0, y: 0 }) { +export function characterRotation(character: Pick, pose: Pick, rotation = { x: -5.9, y: -24.2, z: 0 }, cursor: Gaze = { x: 0, y: 0 }) { if (character.trueFront) return { x: 0, y: 0, z: 0 } return { x: character.followRotation ? -cursor.y * 16 : rotation.x + pose.rotationX, - y: rotation.y + pose.rotationY + (character.followRotation ? cursor.x * 28 : 0), - z: rotation.z + pose.rotationZ + y: character.followRotation ? cursor.x * 28 : rotation.y + pose.rotationY, + z: character.followRotation ? 0 : rotation.z + pose.rotationZ } } -/** One rounded light region; the body-following toggle activates - * a subtle light-source shift. The shared eased cursor keeps motion smooth. */ -export function toonLightOffset(character: Pick, cursor: Gaze = { x: 0, y: 0 }, reducedMotion = false): Gaze { - const look = !reducedMotion && character.followRotation ? clampGaze(cursor) : { x: 0, y: 0 } +/** Anchor the rounded highlight to the face direction shown by the orbit marker. + * Match the former cursor travel at 28° yaw / 16° pitch, with a bounded rim. */ +export function toonLightOffset(orientation: THREE.Quaternion): Gaze { + const facing = new THREE.Vector3(0, 0, 1).applyQuaternion(orientation) + const look = clampGaze({ x: facing.x / Math.sin(28 * Math.PI / 180), y: facing.y / Math.sin(16 * Math.PI / 180) }) return { x: -.015 + look.x * .045, y: .015 + look.y * .045 } } /** Size adaptation is render-only; larger sizes restore the authored appearance. */ export function faceForSize(character: Character, sample: Sample, size: number) { - const simpleEyes = size > 16 && size <= 48 - const frontOnly = size <= 24 - const flatFill = size < 48 + const simpleEyes = size > 12 && size <= 48 + const frontOnly = size <= 32 + const flatFill = size < 40 && size !== 32 + const smallerSpacedEyes = size === 24 || size === 32 + if (size === 16) sample = { ...sample, pose: { ...sample.pose, faceScale: sample.pose.faceScale * 1.2 } } return { - character: simpleEyes || flatFill ? { ...character, ...(simpleEyes ? { iris: false } : {}), ...(flatFill ? { toon: false, shadow: false } : {}), ...(frontOnly ? { trueFront: true, lockPosition: true, followRotation: false } : {}) } : character, - sample: simpleEyes || frontOnly ? { ...sample, pose: { ...sample.pose, faceScale: sample.pose.faceScale * (simpleEyes ? 1.3 : 1), eyeSize: sample.pose.eyeSize * (size === 24 ? 1.2 : 1), ...(frontOnly ? { squash: 1 } : {}), ...(size === 24 ? { gazeX: -4, gazeY: 0, leftX: 0, rightX: 0, leftY: 0, rightY: 0, spacing: BASE_POSE.spacing * .7, faceY: BASE_POSE.faceY } : {}) } } : sample, + character: simpleEyes || flatFill ? { ...character, ...(simpleEyes ? { iris: false } : {}), ...(flatFill ? { toon: false } : {}), ...(size < 40 ? { shadow: false } : {}), ...(frontOnly ? { trueFront: true, lockPosition: true, followRotation: false } : {}) } : character, + sample: simpleEyes || frontOnly ? { ...sample, ...(frontOnly ? { faceLayers: undefined } : {}), pose: { ...sample.pose, faceScale: sample.pose.faceScale * (simpleEyes ? 1.3 : 1), eyeSize: sample.pose.eyeSize * (size === 24 ? 1.2 : 1) * (size >= 16 && size <= 32 ? 1.1664 : 1) * (smallerSpacedEyes ? .8 : 1), ...(frontOnly ? { squash: 1 } : {}), ...(frontOnly ? { mouth: 'smile' as const, prop: 'none' as const, drool: false, tears: false, blush: 0, brows: 'none' as const, mouthWidth: BASE_POSE.mouthWidth * (size >= 16 ? 1.44 : 1), mouthStroke: BASE_POSE.mouthStroke * (size >= 16 ? 1.44 : 1), gazeX: -4, gazeY: 0, leftX: 0, rightX: 0, leftY: 0, rightY: 0, spacing: BASE_POSE.spacing * .77 * (smallerSpacedEyes ? 1.2 : 1), faceY: BASE_POSE.faceY + (size === 32 ? .025 * bodyHeight(character.shape) : 0) } : {}) } } : sample, simpleEyes } } @@ -74,7 +80,7 @@ const fragmentShader = ` float t = clamp(0.5 + vPosition.y / bodyHeight * cos(angle) + vPosition.x * sin(angle), 0.0, 1.0); vec3 color = mix(colorA, mix(colorB, colorA, t), gradientOn); // The light fill uses an inset copy of the silhouette, with one crisp shade step. - float shade = mix(0.8, 1.0, insetFill); + float shade = mix(0.82, 1.0, insetFill); gl_FragColor = vec4(color * mix(1.0, shade, toonOn), 1.0); #include } @@ -138,7 +144,10 @@ export function resolveEyeGazes(character: Character, pose: Pose, blink: number, function drop(ctx: CanvasRenderingContext2D, x: number, y: number, r: number) { ctx.beginPath(); ctx.moveTo(x, y - r * 1.5); ctx.bezierCurveTo(x - r * .25, y - r * .8, x - r, y - r * .2, x - r, y + r * .35); ctx.bezierCurveTo(x - r, y + r * 1.65, x + r, y + r * 1.65, x + r, y + r * .35); ctx.bezierCurveTo(x + r, y - r * .2, x + r * .25, y - r * .8, x, y - r * 1.5); ctx.fill() } -export function drawFace(ctx: CanvasRenderingContext2D, pose: Pose, character: Character, blink: number, detail: Detail, gaze: { x: number; y: number }, effects: { phase?: number; tearAmount?: number; eyeGazes?: EyeGazes; faceLayers?: FaceLayer[]; simpleEyes?: boolean } = {}) { +export function compactMouthOffset(size: number, faceScale: number, viewHeight: number, outputHeight: number) { + return (size >= 16 && size < 32) ? 512 * viewHeight / outputHeight / (.57 * faceScale) : 0 +} +export function drawFace(ctx: CanvasRenderingContext2D, pose: Pose, character: Character, blink: number, detail: Detail, gaze: { x: number; y: number }, effects: { phase?: number; tearAmount?: number; eyeGazes?: EyeGazes; faceLayers?: FaceLayer[]; simpleEyes?: boolean; mouthFollowsEyes?: boolean; mouthOffset?: number } = {}) { ctx.clearRect(0, 0, 512, 512) if (detail === 'body') return const ink = character.eyeColor, eyeY = detail === 'eyes' ? 254 : 197, spacing = 103 * pose.spacing @@ -202,15 +211,9 @@ export function drawFace(ctx: CanvasRenderingContext2D, pose: Pose, character: C } } ctx.restore() - if (detail === 'full' && layers) { + if (detail === 'full' && (layers || pose.brows !== 'none')) { ctx.save(); ctx.translate(x, y - r * (1.45 + .35 * pupilAmount)); ctx.scale(1, faceAspect) - drawMorphBrow(ctx, layers, r, side, ink); ctx.restore() - } else if (detail === 'full' && pose.brows !== 'none') { - ctx.save(); ctx.translate(x, y - (pupilEyes ? r * 1.8 : r * 1.45)); ctx.scale(1, faceAspect); ctx.strokeStyle = ink; ctx.lineWidth = 12; ctx.beginPath() - if (pose.brows === 'raised') { ctx.arc(0, 10, r * .85, Math.PI * 1.15, Math.PI * 1.85) } - else if (pose.brows === 'worried') { ctx.moveTo(side * r, 2); ctx.quadraticCurveTo(-side * r * .1, 10, -side * r * .75, -16) } - else { ctx.moveTo(side * r, -10); ctx.lineTo(-side * r * .75, 8) } - ctx.stroke(); ctx.restore() + drawMorphBrow(ctx, layers ?? faceLayers(pose), r, side, ink, pose.browStroke ?? 1, pose.browLength ?? 1); ctx.restore() } if (pose.tears && detail === 'full') { const phase = fract((effects.phase ?? .35) + (side < 0 ? .46 : 0)), alpha = effectEnvelope(phase) * (effects.tearAmount ?? 1) @@ -218,11 +221,13 @@ export function drawFace(ctx: CanvasRenderingContext2D, pose: Pose, character: C } } if (detail === 'eyes') return + const mouthX = effects.mouthFollowsEyes ? 256 + gx * (1 - pupilAmount) + (pose.leftX + pose.rightX) / 2 : 256 + gx * .25 + const mouthY = (effects.mouthFollowsEyes ? 275 + gy : 293 + gy * .25) + (effects.mouthOffset ?? 0) if (layers) { - ctx.save(); ctx.translate(256 + gx * .25, 293 + gy * .25); ctx.scale(1, faceAspect) + ctx.save(); ctx.translate(mouthX, mouthY); ctx.scale(1, faceAspect) drawMorphMouth(ctx, pose, layers, ink); ctx.restore(); return } - const x = 256 + gx * .25, y = (pose.mouth === 'cry' ? 329 : 293) + gy * .25 + const x = mouthX, y = pose.mouth === 'cry' ? 329 + gy * .25 : mouthY const broad = ['open', 'grin', 'cry'].includes(pose.mouth), w = (broad ? 124 : pose.mouth === 'oh' ? 51 : 61) * pose.mouthWidth const line = 17 * pose.mouthStroke ctx.save(); ctx.translate(x, y); ctx.scale(1, faceAspect); ctx.fillStyle = ink; ctx.strokeStyle = ink; ctx.lineWidth = line; ctx.beginPath() @@ -260,15 +265,15 @@ export function drawFace(ctx: CanvasRenderingContext2D, pose: Pose, character: C ctx.restore() } -export function drawProp(ctx: CanvasRenderingContext2D, prop: Pose['prop'], color: string, phase?: number) { +export function drawProp(ctx: CanvasRenderingContext2D, prop: Pose['prop'], color: string, phase?: number, settings: Partial = {}) { ctx.clearRect(0, 0, 256, 256) - const count = ['zzz', 'sparkle', 'heart'].includes(prop) ? 3 : 1 - for (let i = 0; i < count; i++) { - const p = fract((phase ?? .34) + i * .29), ease = p * p - ctx.save(); ctx.globalAlpha = phase === undefined ? 1 : effectEnvelope(p) - ctx.translate(prop === 'zzz' ? 45 + i * 64 + ease * 36 : count === 1 ? 128 : 51 + i * 69, prop === 'zzz' ? 204 - i * 58 - ease * 54 : count === 1 ? 132 - ease * 26 : 198 - i * 60 - ease * 38) - const scale = phase === undefined ? 1 : .62 + .38 * smoothstep(0, .38, p) - ctx.scale(scale, scale); ctx.fillStyle = color; ctx.strokeStyle = color; ctx.lineWidth = 11; ctx.lineCap = 'round'; ctx.lineJoin = 'round' + const layout = particleLayout(prop, phase, settings) + ctx.save(); ctx.translate(128, 128); ctx.scale(256 / layout.extent, 256 / layout.extent); ctx.translate(-128, -128) + for (const particle of layout.particles) { + const i = particle.index + ctx.save(); ctx.globalAlpha = particle.alpha + ctx.translate(particle.x, particle.y); ctx.scale(particle.scale, particle.scale) + ctx.fillStyle = color; ctx.strokeStyle = color; ctx.lineWidth = 11; ctx.lineCap = 'round'; ctx.lineJoin = 'round' if (prop === 'zzz') { const r = 14 + i * 5; ctx.beginPath(); ctx.moveTo(-r, -r); ctx.lineTo(r, -r); ctx.lineTo(-r, r); ctx.lineTo(r, r); ctx.stroke() } else if (prop === 'sparkle') star(ctx, 0, 0, 23 + i * 4, 4) else if (prop === 'heart') heart(ctx, 0, 0, 17 + i * 3) @@ -277,12 +282,14 @@ export function drawProp(ctx: CanvasRenderingContext2D, prop: Pose['prop'], colo else if (prop === 'crown') { ctx.beginPath(); ctx.moveTo(-78, 42); ctx.lineTo(-94, -45); ctx.lineTo(-35, -3); ctx.lineTo(0, -67); ctx.lineTo(35, -3); ctx.lineTo(94, -45); ctx.lineTo(78, 42); ctx.closePath(); ctx.fill() } ctx.restore() } + ctx.restore() } export class CharacterRenderer { readonly canvas: HTMLCanvasElement | null readonly gl: THREE.WebGLRenderer | null private scene = new THREE.Scene() + private shadowScene = new THREE.Scene() private camera = new THREE.OrthographicCamera(-1, 1, 1, -1, .1, 30) private root = new THREE.Group() private body: THREE.Mesh @@ -335,7 +342,7 @@ export class CharacterRenderer { } this.prop = new THREE.Sprite(new THREE.SpriteMaterial({ map: this.propTexture, transparent: true, depthWrite: false, toneMapped: false })) this.shadow = new THREE.Mesh(new THREE.CircleGeometry(.35, 64), new THREE.MeshBasicMaterial({ color: '#809299', transparent: true, opacity: .2, depthWrite: false })) - this.root.add(this.body, this.lightFill, this.face, this.prop); this.scene.add(this.root, this.shadow) + this.root.add(this.body, this.lightFill, this.face, this.prop); this.scene.add(this.root); this.shadowScene.add(this.shadow) this.camera.position.set(0, 0, 8); this.camera.lookAt(0, 0, 0) this.resize(options.width, options.height, options.displaySize) } @@ -367,9 +374,9 @@ export class CharacterRenderer { const fillScale = this.shape === 'capsule' ? 1 : .85 this.lightFill.scale.setScalar(fillScale) this.root.updateMatrixWorld(true) - // Shift the inset toward the pointer in the camera plane, not the tilted + // Shift the inset toward the face marker in the camera plane, not the tilted // body's axes. Bound travel by the narrowest stretch to retain a dark rim. - const lightTarget = toonLightOffset(character, this.options.cursor, this.options.reducedMotion) + const lightTarget = toonLightOffset(this.root.quaternion) const lightScale = Math.min(this.root.scale.x, this.root.scale.y, this.root.scale.z) const lightOffset = new THREE.Vector3(lightTarget.x * lightScale, lightTarget.y * lightScale, 0).applyMatrix4(new THREE.Matrix4().copy(this.root.matrixWorld).invert()) .sub(new THREE.Vector3().applyMatrix4(new THREE.Matrix4().copy(this.root.matrixWorld).invert())) @@ -383,7 +390,7 @@ export class CharacterRenderer { // Small assets use a tight body fit instead of the companion preview padding. const padding = this.options.framing === 'character' ? .82 : displaySize <= 128 ? .55 : .72 let half = Math.max(height * padding, padding / aspect) / zoom - if (displaySize === 48 || displaySize === 96) { + if (displaySize > 32 && displaySize <= 128) { // A sphere bounds every rotation, avoiding angle-dependent zoom changes. if (!this.body.geometry.boundingSphere) this.body.geometry.computeBoundingSphere() const sphere = this.body.geometry.boundingSphere! @@ -398,11 +405,12 @@ export class CharacterRenderer { const reduced = !!this.options.reducedMotion const screenGaze = reduced ? { x: 0, y: 0 } : gaze const turnGaze = !reduced && !character.followCursor && character.followRotation && !character.trueFront ? this.options.cursor ?? { x: 0, y: 0 } : { x: 0, y: 0 } - const local = character.iris ? new THREE.Vector3(screenGaze.x + turnGaze.x * .5, screenGaze.y + turnGaze.y * .5, 0).applyQuaternion(this.root.quaternion.clone().invert()) : screenGaze - const faceGaze = displaySize === 24 ? { x: 0, y: 0 } : irisRestGaze(character, local, reduced) + const local = character.iris ? new THREE.Vector3(screenGaze.x + turnGaze.x * .575, screenGaze.y + turnGaze.y * .575, 0).applyQuaternion(this.root.quaternion.clone().invert()) : screenGaze + const faceGaze = displaySize <= 32 ? { x: 0, y: 0 } : irisRestGaze(character, local, reduced) this.resolvedEyes = !appearance.simpleEyes && detail === 'full' && (character.iris || pose.eye === 'pupil' || sample.faceLayers?.some(l => l.traits.eye === 'pupil')) ? (!reduced ? this.options.eyeGazes : undefined) ?? resolveEyeGazes(character, reduced ? { ...pose, gazeX: 0, gazeY: 0 } : pose, sample.blink, this.root.matrixWorld, this.camera, faceGaze, !reduced && character.followCursor ? this.options.pointerLook : undefined, sample.faceLayers) : undefined - const key = JSON.stringify([pose, sample.faceLayers, character.eyeColor, character.iris, appearance.simpleEyes, sample.blink.toFixed(3), pose.tears ? sample.effectPhase?.toFixed(2) : 0, sample.tearAmount, detail, faceGaze.x.toFixed(3), faceGaze.y.toFixed(3), this.resolvedEyes]) - if (this.faceCtx && this.faceTexture && key !== this.lastFaceKey) { drawFace(this.faceCtx, pose, character, sample.blink, detail, faceGaze, { phase: sample.effectPhase, tearAmount: sample.tearAmount, eyeGazes: this.resolvedEyes, faceLayers: sample.faceLayers, simpleEyes: appearance.simpleEyes }); this.faceTexture.needsUpdate = true; this.lastFaceKey = key } + const mouthOffset = compactMouthOffset(displaySize, pose.faceScale, half * 2, displaySize) + const key = JSON.stringify([mouthOffset, pose, sample.faceLayers, character.eyeColor, character.iris, appearance.simpleEyes, sample.blink.toFixed(3), pose.tears ? sample.effectPhase?.toFixed(2) : 0, sample.tearAmount, detail, faceGaze.x.toFixed(3), faceGaze.y.toFixed(3), this.resolvedEyes]) + if (this.faceCtx && this.faceTexture && key !== this.lastFaceKey) { drawFace(this.faceCtx, pose, character, sample.blink, detail, faceGaze, { phase: sample.effectPhase, tearAmount: sample.tearAmount, eyeGazes: this.resolvedEyes, faceLayers: sample.faceLayers, simpleEyes: appearance.simpleEyes, mouthOffset, mouthFollowsEyes: displaySize >= 16 && displaySize <= 32 }); this.faceTexture.needsUpdate = true; this.lastFaceKey = key } const vertices = this.face.geometry.attributes.position! const uv = this.face.geometry.attributes.uv! const valid = this.face.geometry.attributes.faceValid! @@ -421,18 +429,29 @@ export class CharacterRenderer { this.prop.visible = detail === 'full' && pose.prop !== 'none' this.prop.material.depthTest = pose.prop !== 'zzz' this.prop.renderOrder = pose.prop === 'zzz' ? 2 : 0 - const propKey = `${pose.prop}:${sample.effectPhase?.toFixed(2) ?? 'still'}` - if (this.propCtx && this.propTexture && propKey !== this.lastProp) { drawProp(this.propCtx, pose.prop, pose.prop === 'heart' ? '#ff768c' : pose.prop === 'sweat' ? '#b7e9ff' : '#ffd362', sample.effectPhase); this.propTexture.needsUpdate = true; this.lastProp = propKey } + const propKey = `${pose.prop}:${pose.propSize}:${pose.propCount}:${pose.propOutward}:${sample.effectPhase?.toFixed(2) ?? 'still'}` + if (this.propCtx && this.propTexture && propKey !== this.lastProp) { drawProp(this.propCtx, pose.prop, pose.prop === 'heart' ? '#ff768c' : pose.prop === 'sweat' ? '#b7e9ff' : '#ffd362', sample.effectPhase, pose); this.propTexture.needsUpdate = true; this.lastProp = propKey } const propSize = this.shape === 'capsule' ? .42 : .32 - this.prop.scale.setScalar(propSize * (.7 + .3 * (sample.propAmount ?? 1))); this.prop.material.opacity = sample.propAmount ?? 1 + this.prop.scale.setScalar(propSize * particleLayout(pose.prop, sample.effectPhase, pose).extent / 256 * (.7 + .3 * (sample.propAmount ?? 1))); this.prop.material.opacity = sample.propAmount ?? 1 this.prop.position.set(pose.prop === 'crown' ? 0 : .56, pose.prop === 'crown' ? height / 2 + .08 : height * .29, .15) this.shadow.visible = character.shadow && detail === 'full' this.shadow.position.set(0, -height * .58, -.2) this.shadow.scale.set((1 - (character.lockPosition ? 0 : sample.bob) * .06) * (this.shape === 'capsule' ? 1 : .95), .12, 1) const bg = this.options.background if (bg) this.gl?.setClearColor(bg, 1); else this.gl?.setClearColor(0x000000, 0) - this.scene.updateMatrixWorld(true) - this.gl?.render(this.scene, this.camera) + // World matrices are updated explicitly: without a GL render (the Node + // SVG snapshot path) nothing else would, and snapshotScene reads them. + this.shadowScene.updateMatrixWorld(true); this.scene.updateMatrixWorld(true) + // Composite the ground shadow first: body rotation must never bring it + // in front of the silhouette or the toon fill, which does not write depth. + if (this.gl) { + this.gl.autoClear = true + this.gl.render(this.shadowScene, this.camera) + this.gl.autoClear = false + this.gl.clearDepth() + this.gl.render(this.scene, this.camera) + this.gl.autoClear = true + } this.captured = { character, sample, gaze: faceGaze, simpleEyes: appearance.simpleEyes } } snapshotScene() { diff --git a/packages/shared/src/cliplab/runtime.ts b/packages/shared/src/cliplab/runtime.ts index e73b5b91ec..a197f0f0eb 100644 --- a/packages/shared/src/cliplab/runtime.ts +++ b/packages/shared/src/cliplab/runtime.ts @@ -51,19 +51,23 @@ export function createCharacter(target: HTMLElement, definition: Definition, opt if (destroyed) return try { render() } catch { playing = false; sync(); options.onError?.() } } - const resize = new ResizeObserver(() => { - try { renderer.resize(target.clientWidth || 256, target.clientHeight || 256, options.displaySize); if (visible) renderSafely() } - catch { playing = false; sync(); options.onError?.() } - }) - const intersection = new IntersectionObserver(entries => { visible = entries[0]?.isIntersecting ?? false; sync() }) + // Both observers are constructed inside the guarded block below: the renderer + // and its canvas already exist by then, so a missing observer must release + // them through destroy() rather than leak a WebGL context behind a fallback. + let resize: ResizeObserver | null = null, intersection: IntersectionObserver | null = null const contextLost = (event: Event) => { event.preventDefault(); playing = false; sync(); options.onError?.() } function destroy() { if (destroyed) return - destroyed = true; sync(); resize.disconnect(); intersection.disconnect() + destroyed = true; sync(); resize?.disconnect(); intersection?.disconnect() document.removeEventListener('visibilitychange', sync); reduced.removeEventListener('change', sync); coarse.removeEventListener('change', sync) canvas.removeEventListener('webglcontextlost', contextLost); renderer.dispose(); canvas.remove() } try { + resize = new ResizeObserver(() => { + try { renderer.resize(target.clientWidth || 256, target.clientHeight || 256, options.displaySize); if (visible) renderSafely() } + catch { playing = false; sync(); options.onError?.() } + }) + intersection = new IntersectionObserver(entries => { visible = entries[0]?.isIntersecting ?? false; sync() }) resize.observe(target); intersection.observe(target) document.addEventListener('visibilitychange', sync); reduced.addEventListener('change', sync); coarse.addEventListener('change', sync) canvas.addEventListener('webglcontextlost', contextLost) diff --git a/packages/shared/src/cliplab/svg-canvas.ts b/packages/shared/src/cliplab/svg-canvas.ts index 2cf03183e7..2b1bb63174 100644 --- a/packages/shared/src/cliplab/svg-canvas.ts +++ b/packages/shared/src/cliplab/svg-canvas.ts @@ -1,4 +1,4 @@ -// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE. +// Vendored from ClipLab 987b6db0 (v0.2.0); see PROVENANCE.md and LICENSE. import { flatten, pathData, strokeOutline, type ProjectPoint } from './svg-path.js' // A small vector recorder for the Canvas 2D operations used by ClipLab's faces. // Paths stay editable; no bitmap or external asset is embedded in the SVG. diff --git a/packages/shared/src/cliplab/svg-path.ts b/packages/shared/src/cliplab/svg-path.ts index ae72bcafa9..69ab5fc506 100644 --- a/packages/shared/src/cliplab/svg-path.ts +++ b/packages/shared/src/cliplab/svg-path.ts @@ -1,4 +1,4 @@ -// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE. +// Vendored from ClipLab 987b6db0 (v0.2.0); see PROVENANCE.md and LICENSE. /** Compact projected vector paths. Curves are flattened below a display pixel, * then simplified after projection; strokes become filled outlines before warping. */ export type Point = { x: number; y: number } diff --git a/packages/shared/src/cliplab/svg-snapshot.ts b/packages/shared/src/cliplab/svg-snapshot.ts index 201f7616b8..f1ef8e5d8e 100644 --- a/packages/shared/src/cliplab/svg-snapshot.ts +++ b/packages/shared/src/cliplab/svg-snapshot.ts @@ -1,6 +1,6 @@ -// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE. +// Vendored from ClipLab 987b6db0 (v0.2.0); see PROVENANCE.md and LICENSE. import * as THREE from 'three' -import { drawFace, drawProp, type CharacterRenderer } from './renderer.js' +import { compactMouthOffset, drawFace, drawProp, type CharacterRenderer } from './renderer.js' import { detailAt } from './model.js' import { SvgCanvas, number as n, xml } from './svg-canvas.js' import { boundaryContours, pathData, type Point, type ProjectPoint } from './svg-path.js' @@ -90,7 +90,7 @@ export function snapshotSvg(snapshot: Snapshot, prefix = `cliplab-${crypto.rando const surface = (mesh: THREE.Mesh, label: string) => { const data = triangles(mesh, true), u = mesh.material.uniforms const colorA = u.colorA!.value as THREE.Color, colorB = (u.colorB!.value as THREE.Color).clone().lerp(colorA, 1 - u.gradientOn!.value) - const shade = u.toonOn!.value && !u.insetFill!.value ? .8 : 1 + const shade = u.toonOn!.value && !u.insetFill!.value ? .82 : 1 const gradient = fitGradient(data.triangles), { x, y, t, gx, gy } = gradient, length = gx * gx + gy * gy let fill = shadedColor(colorA, shade) if (u.gradientOn!.value > 0 && !colorA.equals(colorB)) { @@ -114,7 +114,7 @@ export function snapshotSvg(snapshot: Snapshot, prefix = `cliplab-${crypto.rando const transparent: { z: number; markup: string }[] = [] if (face.visible) { const art = new SvgCanvas('face', faceProjector(face, project)) - drawFace(art as unknown as CanvasRenderingContext2D, sample.pose, character, sample.blink, detailAt(options.displaySize ?? Math.min(width, height)), snapshot.gaze, { phase: sample.effectPhase, tearAmount: sample.tearAmount, eyeGazes: snapshot.eyeGazes, faceLayers: sample.faceLayers, simpleEyes: snapshot.simpleEyes }) + drawFace(art as unknown as CanvasRenderingContext2D, sample.pose, character, sample.blink, detailAt(options.displaySize ?? Math.min(width, height)), snapshot.gaze, { phase: sample.effectPhase, tearAmount: sample.tearAmount, eyeGazes: snapshot.eyeGazes, faceLayers: sample.faceLayers, simpleEyes: snapshot.simpleEyes, mouthOffset: compactMouthOffset(options.displaySize ?? Math.min(width, height), sample.pose.faceScale, camera.top - camera.bottom, options.displaySize ?? Math.min(width, height)), mouthFollowsEyes: (options.displaySize ?? Math.min(width, height)) >= 16 && (options.displaySize ?? Math.min(width, height)) <= 32 }) const valid = face.geometry.getAttribute('faceValid'), data = triangles(face) const visible = data.triangles.filter(t => t.indices.every(i => valid.getX(i) >= .99)) defs.push(``) @@ -124,7 +124,7 @@ export function snapshotSvg(snapshot: Snapshot, prefix = `cliplab-${crypto.rando } if (prop.visible && prop.material.opacity > 0) { const art = new SvgCanvas('prop'), name = sample.pose.prop - drawProp(art as unknown as CanvasRenderingContext2D, name, name === 'heart' ? '#ff768c' : name === 'sweat' ? '#b7e9ff' : '#ffd362', sample.effectPhase) + drawProp(art as unknown as CanvasRenderingContext2D, name, name === 'heart' ? '#ff768c' : name === 'sweat' ? '#b7e9ff' : '#ffd362', sample.effectPhase, sample.pose) const center = project(prop.getWorldPosition(new THREE.Vector3())), scale = prop.getWorldScale(new THREE.Vector3()) const w = scale.x * width / (camera.right - camera.left), h = scale.y * height / (camera.top - camera.bottom) const foreground = !prop.material.depthTest diff --git a/scripts/serve-storybook-static.mjs b/scripts/serve-storybook-static.mjs index d5563db774..d97494d29b 100644 --- a/scripts/serve-storybook-static.mjs +++ b/scripts/serve-storybook-static.mjs @@ -66,7 +66,7 @@ const { createLocalDiskStorageProvider } = await import("../server/src/storage/l const cache = await mkdtemp(join(tmpdir(), "paperclip-storybook-avatars-")); const avatars = createAgentAvatarService(createLocalDiskStorageProvider(cache)); const api = express(); -api.use("/api", agentAvatarRoutes(avatars)); +api.use("/api", agentAvatarRoutes(avatars).router); const server = createServer((req, res) => { const url = new URL(req.url ?? "/", `http://localhost:${port}`); if (url.pathname.startsWith("/api/agent-avatars/")) { api(req, res); return; } diff --git a/scripts/sync-cliplab-character.mjs b/scripts/sync-cliplab-character.mjs new file mode 100644 index 0000000000..0a26537e67 --- /dev/null +++ b/scripts/sync-cliplab-character.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +// The persona character is the ClipLab studio export at +// ui/src/assets/cliplab/onboarding.character.json. The shared package cannot +// import JSON from ui, and its build is plain tsc, so this writes the export +// as a TypeScript module next to the engine. `--check` reports drift. +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const source = path.join(root, "ui/src/assets/cliplab/onboarding.character.json"); +const target = path.join(root, "packages/shared/src/cliplab/character.ts"); +const definition = JSON.parse(readFileSync(source, "utf8")); +const body = `// Generated from ui/src/assets/cliplab/onboarding.character.json by scripts/sync-cliplab-character.mjs. Do not edit. +import type { Definition } from "./model.js"; + +/** The persona character: Tonio's ClipLab studio export, every palette recolours its body. */ +export const PAPERCLIP_CHARACTER: Definition = ${JSON.stringify(definition, null, 2)} as unknown as Definition; +`; +if (process.argv.includes("--check")) { + let current = ""; + try { current = readFileSync(target, "utf8"); } catch { /* missing counts as drift */ } + if (current !== body) { console.error(`packages/shared/src/cliplab/character.ts is out of date; run node scripts/sync-cliplab-character.mjs`); process.exit(1); } + console.log("cliplab character in sync"); +} else { + writeFileSync(target, body); + console.log(`wrote ${path.relative(root, target)}`); +} diff --git a/server/src/__tests__/agent-avatars.test.ts b/server/src/__tests__/agent-avatars.test.ts index 9e5e9f3892..f4670f42fa 100644 --- a/server/src/__tests__/agent-avatars.test.ts +++ b/server/src/__tests__/agent-avatars.test.ts @@ -26,7 +26,7 @@ async function storage() { return createLocalDiskStorageProvider(dir); } async function serve(service: ReturnType) { - const app = express(); app.use("/api", agentAvatarRoutes(service)); + const app = express(); app.use("/api", agentAvatarRoutes(service).router); const server = await new Promise(resolve => { const running = app.listen(0, "127.0.0.1", () => resolve(running)); }); cleanups.push(() => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))); return `http://127.0.0.1:${(server.address() as { port: number }).port}/api/agent-avatars/cap-v1/arctic-blue/rest.png`; diff --git a/server/src/app.ts b/server/src/app.ts index 9f1bf9c765..10c02d5c0b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -624,7 +624,8 @@ export async function createApp( // Mount API routes const api = Router(); - api.use(agentAvatarRoutes()); + const agentAvatars = agentAvatarRoutes(); + api.use(agentAvatars.router); api.use(boardMutationGuard()); api.use( "/health", @@ -1313,6 +1314,9 @@ export async function createApp( hostServiceCleanup.teardown(); await emailChannels.shutdown(); await chatChannels.shutdown(); + // End the avatar worker pool, if a request ever started one, so no + // render outlives the HTTP teardown. + await agentAvatars.close(); // Cancel every live setup-token login session and AWAIT the cancellation, // so each direct child stops and the server releases each lease before the // caller stops the database and the provider. A lease release that diff --git a/server/src/routes/agent-avatars.ts b/server/src/routes/agent-avatars.ts index 3e5c66ffc6..89c738f8b0 100644 --- a/server/src/routes/agent-avatars.ts +++ b/server/src/routes/agent-avatars.ts @@ -15,8 +15,15 @@ const requestSchema = z.object({ scale: z.enum(["1", "2"]).default("1"), }).strict(); -/** Public preset artwork only. No agent lookup or tenant data is exposed. */ -export function agentAvatarRoutes(injected?: ReturnType) { +/** + * Public preset artwork only. No agent lookup or tenant data is exposed. + * + * The service (and its worker pool) is created on the first request, so an + * instance that never serves an avatar never starts a worker; `close` lets + * the application's orderly shutdown end the pool instead of leaving renders + * running past HTTP teardown. + */ +export function agentAvatarRoutes(injected?: ReturnType): { router: ReturnType; close(): Promise } { const router = Router(); let service = injected; router.get("/agent-avatars/:version/:palette/:file", async (req, res) => { @@ -54,5 +61,5 @@ export function agentAvatarRoutes(injected?: ReturnType { await service?.close(); } }; } diff --git a/ui/src/assets/cliplab/onboarding.character.json b/ui/src/assets/cliplab/onboarding.character.json new file mode 100644 index 0000000000..85e2b6a01e --- /dev/null +++ b/ui/src/assets/cliplab/onboarding.character.json @@ -0,0 +1,2322 @@ +{ + "version": 1, + "character": { + "id": "character-5128bda1", + "name": "Milo copy", + "shape": "cap", + "color": "#0f57ff", + "color2": "#4deaff", + "gradient": true, + "gradientAngle": 20, + "toon": true, + "trueFront": false, + "lockPosition": true, + "eyeColor": "#172023", + "iris": false, + "elevated": true, + "elevation": 0.115, + "shadow": true, + "motion": 0.45, + "speed": 1, + "blink": true, + "blinkInterval": 6, + "followCursor": false, + "followRotation": false + }, + "expressions": [ + { + "id": "idle", + "name": "Idle", + "description": "A little breath. A quiet smile.", + "beats": [ + { + "id": "idle-1", + "name": "Settle", + "duration": 1.8, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1.12, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.74, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1.6 + } + }, + { + "id": "idle-2", + "name": "Look around", + "duration": 1.4, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0.22, + "gazeY": 0, + "mouthWidth": 1.12, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 9, + "rotationZ": -3, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1.6 + } + }, + { + "id": "idle-3", + "name": "Return", + "duration": 1, + "pose": { + "eye": "dot", + "mouth": "line", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1.12, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 33, + "rightY": 33, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 8, + "rotationY": 0, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1.6 + } + }, + { + "id": "beat-57cf6bfb", + "name": "Look around", + "duration": 1.4, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0.22, + "gazeY": 0, + "mouthWidth": 1.12, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 5, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1.6 + } + } + ] + }, + { + "id": "listening", + "name": "Listening", + "description": "Present, curious, and all ears.", + "beats": [ + { + "id": "listen-1", + "name": "Notice", + "duration": 1.2, + "pose": { + "eye": "wide", + "mouth": "line", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1.06, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": -4, + "rotationY": 0, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "listen-2", + "name": "Lean in", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1.13, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": -7, + "rotationY": 0, + "rotationZ": 6, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "listen-3", + "name": "Nod", + "duration": 1.1, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 5, + "rotationY": 0, + "rotationZ": 3, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + } + ] + }, + { + "id": "thinking", + "name": "Thinking", + "description": "A glance up. Something is taking shape.", + "beats": [ + { + "id": "think-1", + "name": "Wonder", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "oh", + "prop": "question", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0.6, + "gazeY": 0.55, + "mouthWidth": 0.6, + "mouthOpen": 0.23, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": -7, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "think-2", + "name": "Consider", + "duration": 1.6, + "pose": { + "eye": "soft", + "mouth": "wave", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": -0.35, + "gazeY": 0.2, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": -8, + "rotationZ": 4, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "think-3", + "name": "Almost", + "duration": 1.2, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0.25, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": -3, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + } + ] + }, + { + "id": "working", + "name": "Working", + "description": "Small, focused, steady movements.", + "beats": [ + { + "id": "work-1", + "name": "Focus left", + "duration": 1, + "pose": { + "eye": "soft", + "mouth": "line", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": -0.5, + "gazeY": -0.3, + "mouthWidth": 0.65, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 4, + "rotationY": -7, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "work-2", + "name": "Focus right", + "duration": 1, + "pose": { + "eye": "soft", + "mouth": "line", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0.5, + "gazeY": -0.3, + "mouthWidth": 0.65, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 4, + "rotationY": 7, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "work-3", + "name": "Check", + "duration": 1.4, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": -3, + "rotationY": 0, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + } + ] + }, + { + "id": "happy", + "name": "Happy", + "description": "A small open smile says it all.", + "beats": [ + { + "id": "happy-1", + "name": "Light up", + "duration": 0.9, + "pose": { + "eye": "dot", + "mouth": "open", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": -5, + "squash": 1.025, + "tongue": true, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "happy-2", + "name": "Delight", + "duration": 1.3, + "pose": { + "eye": "closed", + "mouth": "open", + "prop": "sparkle", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.75, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": 5, + "squash": 0.97, + "tongue": true, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "happy-3", + "name": "Glow", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "open", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.45, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": -2, + "squash": 1, + "tongue": true, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + } + ] + }, + { + "id": "confused", + "name": "Confused", + "description": "A little uneven. A second look.", + "beats": [ + { + "id": "confused-1", + "name": "Wait", + "duration": 1.2, + "pose": { + "eye": "soft", + "mouth": "wave", + "prop": "question", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 0.7, + "rightScale": 1.1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": 10, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "confused-2", + "name": "Really?", + "duration": 1.4, + "pose": { + "eye": "dot", + "mouth": "oh", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1.1, + "rightScale": 0.7, + "gazeX": -0.2, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.25, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": -9, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "confused-3", + "name": "Hmm", + "duration": 1.3, + "pose": { + "eye": "soft", + "mouth": "frown", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 0.75, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": 4, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + } + ] + }, + { + "id": "sleepy", + "name": "Sleepy", + "description": "Slow breathing, drifting sleep marks.", + "beats": [ + { + "id": "sleep-1", + "name": "Doze", + "duration": 2, + "pose": { + "eye": "closed", + "mouth": "sleep", + "prop": "zzz", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 4, + "rotationY": 0, + "rotationZ": -6, + "squash": 0.97, + "tongue": false, + "teeth": false, + "drool": true, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "sleep-2", + "name": "Breathe in", + "duration": 2, + "pose": { + "eye": "closed", + "mouth": "oh", + "prop": "zzz", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 0.65, + "mouthOpen": 0.25, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": -3, + "squash": 1.025, + "tongue": false, + "teeth": false, + "drool": true, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "sleep-3", + "name": "Breathe out", + "duration": 2, + "pose": { + "eye": "closed", + "mouth": "sleep", + "prop": "zzz", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": -7, + "squash": 0.98, + "tongue": false, + "teeth": false, + "drool": true, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + } + ] + }, + { + "id": "playful", + "name": "Playful", + "description": "A wink, a grin, a little mischief.", + "beats": [ + { + "id": "play-1", + "name": "Peek", + "duration": 1.1, + "pose": { + "eye": "dot", + "mouth": "open", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": -10, + "rotationZ": -8, + "squash": 1, + "tongue": true, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "play-2", + "name": "Wink", + "duration": 1.2, + "pose": { + "eye": "wink", + "mouth": "open", + "prop": "sparkle", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.7, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": 9, + "squash": 1, + "tongue": true, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "play-3", + "name": "Grin", + "duration": 1.3, + "pose": { + "eye": "dot", + "mouth": "open", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.45, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": -3, + "squash": 1, + "tongue": false, + "teeth": true, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + } + ] + }, + { + "id": "loading", + "name": "Loading", + "description": "A slow start, one gradient turn, then a half-second rest.", + "poseExpressionId": "working", + "beats": [ + { + "id": "loading-rotate", + "name": "Rotate", + "duration": 2, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + }, + "gradientAction": "rotate", + "gradientTurns": 4 + } + ] + }, + { + "id": "expression-7287c053", + "name": "Idle side to side 01", + "description": "", + "beats": [ + { + "id": "beat-3b4f9de9", + "name": "Begin", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-9516b036", + "name": "Beat 4", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 11, + "rotationY": 0, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-c3858187", + "name": "turn", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "line", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 4, + "rotationY": 39, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-41ccd5c0", + "name": "Retur", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 4, + "rotationY": 0, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + } + ] + }, + { + "id": "expression-f9a89083", + "name": "Idle side to side 02", + "description": "", + "beats": [ + { + "id": "beat-f9434a07", + "name": "Begin", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": 48, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-68deb476", + "name": "Beat 4", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 11, + "rotationY": 38, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-5c04098e", + "name": "Beat 4", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 0, + "rotationY": -5, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-9546ee11", + "name": "turn", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "line", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 4, + "rotationY": 26, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + } + ] + }, + { + "id": "expression-b20a0ef7", + "name": "Idle side to side 03", + "description": "", + "beats": [ + { + "id": "beat-1b2247b8", + "name": "Begin", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 20, + "rotationY": 38, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-1df49105", + "name": "Beat 4", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": -8, + "rotationY": 44, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-2b9fa4f3", + "name": "turn", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "line", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 4, + "rotationY": 26, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-8bb4cc3c", + "name": "Beat 4", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 12, + "rotationY": -4, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + } + ] + }, + { + "id": "expression-229a39b6", + "name": "Sleepy to wake", + "description": "Slow breathing, drifting sleep marks.", + "beats": [ + { + "id": "beat-3460c6a3", + "name": "Doze", + "duration": 1, + "pose": { + "eye": "closed", + "mouth": "sleep", + "prop": "zzz", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1.2, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 4, + "rotationY": 0, + "rotationZ": -6, + "squash": 0.97, + "tongue": false, + "teeth": false, + "drool": true, + "cheeks": false, + "tears": false, + "mouthStroke": 1.2 + } + }, + { + "id": "beat-8b5d3238", + "name": "Breathe in", + "duration": 0.5, + "pose": { + "eye": "wide", + "mouth": "oh", + "prop": "question", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 0.65, + "mouthOpen": 1, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": -3, + "rotationY": 0, + "rotationZ": -3, + "squash": 1.025, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-c35e2a7e", + "name": "Breathe in", + "duration": 1, + "pose": { + "eye": "wide", + "mouth": "oh", + "prop": "question", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 0.65, + "mouthOpen": 1, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": 11, + "rotationY": 30, + "rotationZ": -3, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1 + } + }, + { + "id": "beat-787f488e", + "name": "Wink", + "duration": 1.2, + "pose": { + "eye": "wink", + "mouth": "open", + "prop": "sparkle", + "propSize": 1.45, + "propCount": 3, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 0.59, + "mouthOpen": 0.71, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.75, + "faceY": 0, + "rotationX": -11, + "rotationY": 9, + "rotationZ": 11, + "squash": 1.05, + "tongue": true, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 0.85 + } + }, + { + "id": "beat-2cafeefa", + "name": "Settle", + "duration": 1.5, + "pose": { + "eye": "dot", + "mouth": "smile", + "prop": "none", + "propSize": 1, + "propCount": 0, + "propOutward": 1, + "faceSet": "set-1", + "brows": "none", + "browStroke": 1, + "browLength": 1, + "blush": 0, + "eyeSize": 1, + "eyeHeight": 1, + "spacing": 1, + "eyeTilt": 0, + "leftScale": 1, + "rightScale": 1, + "gazeX": 0, + "gazeY": 0, + "mouthWidth": 1.12, + "mouthOpen": 0.5, + "leftX": 0, + "rightX": 0, + "leftY": 0, + "rightY": 0, + "leftRotation": 0, + "rightRotation": 0, + "faceScale": 0.74, + "faceY": 0, + "rotationX": 0, + "rotationY": 0, + "rotationZ": 0, + "squash": 1, + "tongue": false, + "teeth": false, + "drool": false, + "cheeks": false, + "tears": false, + "mouthStroke": 1.6 + } + } + ] + } + ], + "animations": [ + { + "id": "idle", + "name": "Idle", + "loop": true, + "steps": [ + { + "id": "idle-step-1", + "expressionId": "idle", + "duration": 5 + } + ] + }, + { + "id": "listening", + "name": "Listening", + "loop": true, + "steps": [ + { + "id": "listening-step-1", + "expressionId": "listening", + "duration": 3.8 + }, + { + "id": "listening-step-2", + "expressionId": "idle", + "duration": 1.6 + } + ] + }, + { + "id": "thinking", + "name": "Thinking", + "loop": true, + "steps": [ + { + "id": "thinking-step-1", + "expressionId": "thinking", + "duration": 4.3 + }, + { + "id": "thinking-step-2", + "expressionId": "working", + "duration": 1.8 + } + ] + }, + { + "id": "working", + "name": "Working", + "loop": true, + "steps": [ + { + "id": "working-step-1", + "expressionId": "working", + "duration": 6.8 + }, + { + "id": "working-step-2", + "expressionId": "thinking", + "duration": 2.2 + } + ] + }, + { + "id": "happy", + "name": "Success", + "loop": true, + "steps": [ + { + "id": "happy-step-1", + "expressionId": "happy", + "duration": 3.7 + }, + { + "id": "happy-step-2", + "expressionId": "playful", + "duration": 2.4 + }, + { + "id": "happy-step-3", + "expressionId": "idle", + "duration": 1.8 + } + ] + }, + { + "id": "confused", + "name": "Confused", + "loop": true, + "steps": [ + { + "id": "confused-step-1", + "expressionId": "confused", + "duration": 3.9 + }, + { + "id": "confused-step-2", + "expressionId": "thinking", + "duration": 2.1 + } + ] + }, + { + "id": "sleepy", + "name": "Sleepy", + "loop": true, + "steps": [ + { + "id": "sleepy-step-1", + "expressionId": "sleepy", + "duration": 6 + } + ] + }, + { + "id": "playful", + "name": "Playful", + "loop": true, + "steps": [ + { + "id": "playful-step-1", + "expressionId": "playful", + "duration": 3.6 + }, + { + "id": "playful-step-2", + "expressionId": "happy", + "duration": 2.2 + } + ] + }, + { + "id": "animation-078668cd", + "name": "New animation", + "loop": true, + "steps": [ + { + "id": "step-fde801f7", + "expressionId": "working", + "duration": 3.4 + } + ] + }, + { + "id": "loading", + "name": "Loading", + "loop": true, + "steps": [ + { + "id": "step-5fd1d737", + "expressionId": "loading", + "duration": 2.5 + } + ] + }, + { + "id": "animation-0dd1e312", + "name": "Listening 2", + "loop": true, + "steps": [ + { + "id": "step-31c18790", + "expressionId": "idle", + "duration": 5 + }, + { + "id": "step-48e70fa6", + "expressionId": "listening", + "duration": 3.8000000000000003 + } + ] + }, + { + "id": "animation-c1df7072", + "name": "idle side to side 01", + "loop": true, + "steps": [ + { + "id": "step-dd427653", + "expressionId": "expression-7287c053", + "duration": 6 + } + ] + }, + { + "id": "animation-0efb6f80", + "name": "idle side to side 02", + "loop": true, + "steps": [ + { + "id": "step-a000b372", + "expressionId": "expression-f9a89083", + "duration": 6 + } + ] + }, + { + "id": "animation-9386f24d", + "name": "idle side to side 03", + "loop": true, + "steps": [ + { + "id": "step-04145e45", + "expressionId": "expression-b20a0ef7", + "duration": 6 + } + ] + }, + { + "id": "animation-2f02a915", + "name": "sleepy to wake", + "loop": false, + "steps": [ + { + "id": "step-7fa9addf", + "expressionId": "expression-229a39b6", + "duration": 5.2 + } + ] + } + ] +} diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 43915d134c..aeeb67b194 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -7,11 +7,11 @@ import type { AiConnectionBinding } from "@paperclipai/shared"; import { storeProviderApiKey } from "../lib/provider-credential"; import { SavedProviderKeySelect, useSavedProviderKeys } from "./onboarding/SavedProviderKeySelect"; import { randomAgentAppearance, resolveAgentAppearance, agentAppearanceSchema } from "@paperclipai/shared"; -import { AgentCharacter } from "./AgentCharacter"; +import { OnboardingCharacter } from "./onboarding/OnboardingCharacter"; import { useEffect, useState, useMemo, useRef } from "react"; import type { ComponentType, CSSProperties } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { MotionConfig, motion } from "motion/react"; +import { AnimatePresence, MotionConfig, motion } from "motion/react"; import type { AdapterEnvironmentTestResult, AgentRole, @@ -132,7 +132,7 @@ import { CredentialModeLink } from "./onboarding/CredentialModeLink"; import { FooterNav, type FooterPrimaryIcon } from "./onboarding/FooterNav"; import { OnboardingHeading } from "./onboarding/OnboardingPrimitives"; import { DEFAULT_AGENT_ROLE } from "../lib/onboarding-agent-role"; -import { capsuleHeroMotion } from "./onboarding/onboarding-motion"; +import { capsuleHeroMotion, capsuleRoomEnter, capsuleRoomExit, heroRoomArrival, heroRoomMotion, ledeMotion, stepContentMotion, titleSwapMotion } from "./onboarding/onboarding-motion"; import { Badge } from "@/components/ui/badge"; import { ArrowLeft, @@ -545,6 +545,26 @@ function OnboardingWizardInner({ // customer mid-flow — and here that would quietly re-open the "create a // company" step to a run that already holds one. const [entryStep, setEntryStep] = useState((saved?.step as Step) ?? initialStep); + /** + * A page that opens straight onto the agent step — a cloud-managed + * workspace arriving from Cloud's naming screen, or a reload — plays the + * hand-off's second half on its first frames rather than mounting cold: the + * first paint holds the naming step's layout (hero room and content closed), + * the next frame opens them. `arrival` is fixed for the mount; `arrived` + * flips once. See heroRoomArrival. + */ + const arrival = entryStep === 3 && beatDelay(1) > 0; + const [arrived, setArrived] = useState(!arrival); + useEffect(() => { + if (arrived) return; + const frame = requestAnimationFrame(() => setArrived(true)); + return () => cancelAnimationFrame(frame); + }, [arrived]); + // The step before this render's, for choosing an entrance that matches + // where the capsule came from; updated after paint, so during the render + // in which the step just changed it still names the departed step. + const lastStep = useRef(step); + useEffect(() => { lastStep.current = step; }, [step]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [modelOpen, setModelOpen] = useState(false); @@ -2345,6 +2365,15 @@ function OnboardingWizardInner({ const showsAgentArcStepper = isAgentArcStep && entryStep >= 3 && !enteredFromCloud; const launchStateIncomplete = step === 5 && (!createdCompanyId || !createdAgentId); + /** + * Whether the step hand-off plays out. Under reduced motion — and where the + * platform cannot be asked, which `beatDelay` reads the same way — the next + * step's content simply takes the departing one's place: a sequence that + * holds the screen for a departure nobody sees is just a slower screen. + */ + const stepHandoff = beatDelay(1) > 0; + const heroRoomTarget = step === 1 || !arrived ? heroRoomMotion.closed : arrival && lastStep.current === entryStep ? heroRoomArrival : heroRoomMotion.open; + const capsuleTarget = step === 1 || !arrived ? capsuleRoomExit : step === 3 && stepHandoff && lastStep.current === 1 ? capsuleRoomEnter : capsuleHeroMotion.animate; const visibleError = error ?? (launchStateIncomplete ? INCOMPLETE_ONBOARDING_STATE_MESSAGE : null); return ( @@ -2465,7 +2494,7 @@ function OnboardingWizardInner({ slot across steps 3–5, so React reuses the DOM node and moving between steps never replays the entrance. It is dormant while the agent is being specified and wakes on Review. */} - {step >= 3 && step <= 5 && ( + {(step === 1 || (step >= 3 && step <= 5)) && ( // reducedMotion="user" defers to the OS setting, so the hero // arrives in place for anyone who asked for less movement. The // token layer zeroes the CSS durations; this covers the JS half. @@ -2480,82 +2509,103 @@ function OnboardingWizardInner({ the character and the title and belonged to neither. 24px against the 36px used elsewhere, a little over a third less. `mb-9` still holds the block off the step content. */} -
+
+ {/* The hero's room. Closed on the naming step — there is + no agent yet — and opened by the hand-off into the + agent step, the capsule springing up inside it as it + grows. The 24px under the name lives inside the room so + a closed room takes no space. A reload straight onto + the arc mounts it open, as before. */} + - {/* Dormant until the agent is actually hired. Review is - the first step where one exists, so that is where it - wakes — the arc's payoff, not a flourish along it. */} - {/* `relative` is load-bearing: the sleep marks anchor - to this box and travel out past its top-right - corner. */} -
- + {/* Dozing and gray until the agent is actually hired. + Review is the first step where one exists, so that is + where it wakes and takes its colour — the arc's + payoff, not a flourish along it. The sequence itself + is the studio's export; see OnboardingCharacter. */} +
+
+ Paperclip works with your subscription or API keys. - ) : ( - <>{agentName.trim() || "Your first agent"} is ready to work! - ) + + {step === 1 + ? "What is the name of your organization?" + : step === 3 + ? "Create your first agent" + : step === 4 + ? "Connect a model" + : "Let's get started..."} + } /> + {/* The lede lives outside the heading primitive so its + room can open and close. The agent step carries none, + as the prototype has it: the capsule and the heading + say what this is, and a sentence restating it only + pushes the fields down. The 8px gap sits inside the + clipped box so a closed lede takes no space at all. + The naming step carries none either: the question is + the whole screen, and Cloud's naming step (which most + walkers see instead) is drawn the same way. */} + +

+ + {step === 4 + ? "Paperclip works with your subscription or API keys." + : `${agentName.trim() || "Your first agent"} is ready to work!`} + +

+
)} {/* Step content */} + {/* Steps 1, 3 and 4 hand their content over inside one presence: + the departing step fades and gives its room back before the + next opens its own, so the footer slides rather than jumps. + See stepContentMotion. */} + {/* `initial` only for an arrival: the step's content opens its + room with the hero's instead of being there already. */} + {/* Step 1: name the organization — the wizard's first screen now - that the Build / Grow front door is gone. Dressed as the arc - steps that follow it (centred heading, same footer pair) - because a customer walks straight from here into them. - - The lede carries the welcome the front door used to: one line, - above the field, so the customer lands somewhere that greets - them rather than on a bare question. */} + that the Build / Grow front door is gone. Its heading and + welcome sit in the shared block above, so the walk into the + agent step swaps words rather than screens. The field is the + agent step's field: same label, same filled surface, same + measure — the two questions the wizard asks present the same + target. */} {step === 1 && ( -
- - {/* The field takes the agent step's measure rather than the - column's, so the two questions the wizard asks — name the - organization, name the agent — present the same target. - The heading stays full width above it, as it does there. */} -
- - +
+ + setCompanyName(e.target.value)} @@ -2568,7 +2618,7 @@ function OnboardingWizardInner({ autoFocus />
-
+ )} {/* Step 3: the name, and only the name. The role picker went with @@ -2578,7 +2628,7 @@ function OnboardingWizardInner({ `general` role; a specific one can be set later, where there is context to choose it in. */} {step === 3 && ( -
+
{/* @@ -2605,12 +2655,12 @@ function OnboardingWizardInner({ autoFocus />
-
+ )} {/* Step 4: Connect a model — adapter + model + env check (capsule above) */} {step === 4 && ( -
+
{/* Sources come from `recommendedAdapters`, not a list written here — that filter is `recommended` in the @@ -3002,8 +3052,9 @@ function OnboardingWizardInner({ />
)} -
+ )} + {/* Step 5: Review — lead is online (shared capsule above) */} {/* Step 5: nothing. The heading names the agent and says it is diff --git a/ui/src/components/onboarding/OnboardingCharacter.test.tsx b/ui/src/components/onboarding/OnboardingCharacter.test.tsx new file mode 100644 index 0000000000..9c26a9a6d8 --- /dev/null +++ b/ui/src/components/onboarding/OnboardingCharacter.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { appearanceForPalette } from "@paperclipai/shared"; +import { CAP_V1_COLORS } from "@paperclipai/shared/cliplab/palette-tokens"; +import type { createCharacter as CreateCharacter } from "@paperclipai/shared/cliplab/runtime"; +import { OnboardingCharacter } from "./OnboardingCharacter"; + +const createCharacter = vi.hoisted(() => vi.fn()); +vi.mock("@paperclipai/shared/cliplab/runtime", () => ({ createCharacter })); + +let root: Root; +let host: HTMLDivElement; +const appearance = appearanceForPalette("arctic-blue"); +function player() { + return { destroy: vi.fn(), seek: vi.fn(), play: vi.fn(), pause: vi.fn(), setDefinition: vi.fn(), setAnimation: vi.fn() }; +} +beforeEach(() => { + createCharacter.mockReset(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("IntersectionObserver", class {}); + vi.stubGlobal("ResizeObserver", class {}); + vi.stubGlobal("WebGLRenderingContext", class {}); + vi.stubGlobal("matchMedia", () => ({ matches: false })); + vi.spyOn(console, "warn").mockImplementation(() => {}); + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); +}); +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); +async function render(awake = false, selectedAppearance = appearance) { + await act(async () => { + root.render(); + }); + await act(async () => { await vi.dynamicImportSettled(); }); +} +function expectFallback() { + expect(host.querySelector("img")?.classList.contains("invisible")).toBe(false); +} + +it("releases the first canvas if its colored twin cannot start", async () => { + const gray = player(); + createCharacter.mockReturnValueOnce(gray).mockImplementationOnce(() => { throw new Error("WebGL context limit"); }); + await render(); + expect(gray.destroy).toHaveBeenCalledOnce(); + expectFallback(); + await render(true); + expect(createCharacter).toHaveBeenCalledTimes(2); +}); + +it("owns the canvas before seeking so synchronous render errors can release it", async () => { + const gray = player(); + createCharacter.mockImplementationOnce((_target, _definition, options) => { + gray.seek.mockImplementationOnce(() => options?.onError?.()); + return gray; + }); + await render(); + expect(gray.destroy).toHaveBeenCalledOnce(); + expect(createCharacter).toHaveBeenCalledOnce(); + expectFallback(); +}); + +it("stops the wake and releases both canvases after a synchronous render error", async () => { + const gray = player(), twin = player(); + createCharacter.mockImplementationOnce((_target, _definition, options) => { + gray.setAnimation.mockImplementationOnce(() => options?.onError?.()); + return gray; + }).mockReturnValueOnce(twin); + await render(); + await render(true); + expect(gray.destroy).toHaveBeenCalledOnce(); + expect(twin.destroy).toHaveBeenCalledOnce(); + expect(gray.play).not.toHaveBeenCalled(); + expect(twin.setAnimation).not.toHaveBeenCalled(); + expectFallback(); +}); + +it("releases both canvases if a wake transition throws", async () => { + const gray = player(), twin = player(); + gray.setAnimation.mockImplementationOnce(() => { throw new Error("renderer unavailable"); }); + createCharacter.mockReturnValueOnce(gray).mockReturnValueOnce(twin); + await render(); + await render(true); + expect(gray.destroy).toHaveBeenCalledOnce(); + expect(twin.destroy).toHaveBeenCalledOnce(); + expectFallback(); +}); + +it.each([false, true])("refreshes the sleeping twin when its palette changes (wake: %s)", async (awake) => { + const gray = player(), twin = player(), nextGray = player(), nextTwin = player(); + createCharacter.mockReturnValueOnce(gray).mockReturnValueOnce(twin) + .mockReturnValueOnce(nextGray).mockReturnValueOnce(nextTwin); + await render(); + await render(awake, appearanceForPalette("coral-mint")); + expect(gray.destroy).toHaveBeenCalledOnce(); + expect(twin.destroy).toHaveBeenCalledOnce(); + const definition = createCharacter.mock.calls[3][1]; + expect(definition.character.color).toBe(CAP_V1_COLORS["coral-mint"].a); + expect(definition.character.color2).toBe(CAP_V1_COLORS["coral-mint"].b); + if (awake) expect(nextTwin.play).toHaveBeenCalledOnce(); +}); diff --git a/ui/src/components/onboarding/OnboardingCharacter.tsx b/ui/src/components/onboarding/OnboardingCharacter.tsx new file mode 100644 index 0000000000..b36cbfee13 --- /dev/null +++ b/ui/src/components/onboarding/OnboardingCharacter.tsx @@ -0,0 +1,190 @@ +import { useEffect, useRef, useState } from "react"; +import { resolveAgentAppearance, type AgentAppearance } from "@paperclipai/shared"; +import { cn } from "@/lib/utils"; +import { AgentAvatar } from "../AgentAvatar"; +import type { createCharacter } from "@paperclipai/shared/cliplab/runtime"; +import type { Definition } from "@paperclipai/shared/cliplab/model"; +import { colorOnboardingDefinition, resolveOnboardingSequences, sequenceDuration, sequenceLeadIn, type OnboardingSequences } from "./onboarding-character"; + +type Player = ReturnType; +type Phase = "asleep" | "awake"; + +export interface OnboardingCharacterProps { + appearance: AgentAppearance; + /** Review is the first step where an agent exists; that is where it wakes. */ + awake: boolean; + className?: string; +} + +/** + * The onboarding hero: gray and dozing while the agent is being specified, + * then — once on Review — it plays the studio's sleepy → wink → idle + * transition while its palette fades in over the gray, and settles into the + * idle loop. + * + * Played by the shared ClipLab engine directly rather than through + * `AgentCharacter`: the arc needs a one-shot that the runtime reports as + * complete (it then continues into the idle loop itself), a page-scoped + * pointer, and two canvases for the colouring-in below. The character is the + * same export every avatar renders, so what wakes here is what the agent + * looks like everywhere after. + * + * Colouring in: the runtime's `setDefinition` restarts playback, so the body + * cannot be recoloured mid-sequence. Instead a second canvas in the agent's + * palette plays the same transition in lock-step above the gray one and fades + * in; the gray canvas is destroyed once it is covered. + * + * Mounting straight onto Review (a reload, or a return to the wizard) shows + * the awake hero without replaying the wake. Reduced motion skips the + * sequence entirely: the runtime holds the first pose under that setting, so + * the hero switches straight to its coloured idle. + */ +export function OnboardingCharacter({ appearance, awake, className }: OnboardingCharacterProps) { + const identity = resolveAgentAppearance(appearance); + const base = useRef(null), overlay = useRef(null); + const players = useRef<{ base: Player | null; overlay: Player | null }>({ base: null, overlay: null }); + const library = useRef<{ create: typeof createCharacter; definition: Definition; sequences: OnboardingSequences } | null>(null); + // What the live canvas currently shows, so a prop change is a transition + // from something rather than a re-mount. Starts where the props say. + const phase = useRef(awake ? "awake" : "asleep"); + const renderedPalette = useRef(null); + const timers = useRef([]); + const [ready, setReady] = useState(false), [failed, setFailed] = useState(false), [colored, setColored] = useState(awake); + const [wakeSeconds, setWakeSeconds] = useState(0); + + const clearTimers = () => { for (const id of timers.current) window.clearTimeout(id); timers.current = []; }; + const destroy = (key: "base" | "overlay") => { players.current[key]?.destroy(); players.current[key] = null; }; + // A renderer failure (render, resize, context loss) hands the hero to the + // still portrait — and releases both canvases, observers and contexts, which + // otherwise stay allocated behind the fallback until the wizard unmounts. + const fail = () => { clearTimers(); destroy("overlay"); destroy("base"); setReady(false); setFailed(true); }; + // Note: after a wake the live player sits in the overlay span; `mount` clears both spans. + const reducedMotion = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches; + + // Body-following, page-scoped: the engine turns the body toward the + // pointer only while eye-following is off. Framing is the engine's + // character framing, the same as every live AgentCharacter. + const FOLLOW = { followCursor: false, followRotation: true, trackingScope: "page" as const, displaySize: 160 }; + + /** + * A fresh canvas (or pair) in the given phase; no transition. The loops + * turn the body toward the pointer — body only, the eyes stay as authored + * (the runtime only turns the body when eye-following is off). + * + * Asleep also mounts the coloured twin at opacity 0, playing the same loop + * with the same options. The runtime fixes follow options and pointer state + * at creation, so a twin created later would face front while the gray + * canvas had already turned — a visible ghost through the crossfade. Created + * together, both hear the same pointer events and stay in lock-step. + * Each loop starts past its wrap-around ease so it opens on its own first beat. + */ + function mount(next: Phase) { + const lib = library.current; + if (!lib || !base.current || !overlay.current) return; + clearTimers(); destroy("overlay"); destroy("base"); + const animation = next === "asleep" ? lib.sequences.asleep : lib.sequences.awake; + const leadIn = sequenceLeadIn(lib.definition, animation); + const player = lib.create(base.current, colorOnboardingDefinition(lib.definition, identity, next === "asleep"), { animation, ...FOLLOW, onError: fail }); + players.current.base = player; + player.seek(leadIn); + if (players.current.base !== player) return; + if (next === "asleep") { + const twin = lib.create(overlay.current, colorOnboardingDefinition(lib.definition, identity, false), { animation, ...FOLLOW, onError: fail }); + players.current.overlay = twin; + twin.seek(leadIn); + if (players.current.overlay !== twin) return; + } + phase.current = next; renderedPalette.current = identity.paletteId; setColored(next === "awake"); + } + + /** The arc's payoff: both canvases play the transition together while the palette fades in. */ + function wake() { + const lib = library.current, gray = players.current.base, twin = players.current.overlay; + if (!lib || !gray || !twin || reducedMotion()) { mount("awake"); return; } + clearTimers(); + // Skip the sequence's wrap-around ease so it opens asleep, not on a + // blend of its own idle end; see sequenceLeadIn. + const leadIn = sequenceLeadIn(lib.definition, lib.sequences.wake); + const seconds = Math.max(0, sequenceDuration(lib.definition, lib.sequences.wake) - leadIn); + setWakeSeconds(seconds); + // Same tick, same offset: the two canvases stay in lock-step for the fade. + for (const player of [gray, twin]) { + player.setAnimation(lib.sequences.wake); + if (players.current.base !== gray) return; + player.seek(leadIn); + if (players.current.base !== gray) return; + player.play(); + } + phase.current = "awake"; + // Next frame, so the fade transitions from the twin's opacity 0. + timers.current.push(window.setTimeout(() => setColored(true), 0)); + // The engine continues a finished one-shot into the idle loop by itself. + // The gray canvas is fully covered by then; the twin becomes the live + // canvas as it is — same pointer state, no re-creation, so no snap — and + // the idle loop starts past its wrap-around ease. + timers.current.push(window.setTimeout(() => { + destroy("base"); + players.current.base = players.current.overlay; players.current.overlay = null; + players.current.base?.setAnimation(lib.sequences.awake); + players.current.base?.seek(sequenceLeadIn(lib.definition, lib.sequences.awake)); + }, Math.ceil(seconds * 1000) + 50)); + } + + useEffect(() => { + // The runtime observes its container and needs a GL canvas; where neither + // exists (jsdom, some embedded views) the still portrait is the whole hero. + if (typeof IntersectionObserver !== "function" || typeof ResizeObserver !== "function" || typeof WebGLRenderingContext === "undefined") { setFailed(true); return; } + let disposed = false; + setReady(false); + void Promise.all([import("@paperclipai/shared/cliplab/runtime"), import("@paperclipai/shared/cliplab/character")]).then(([runtime, exported]) => { + if (disposed) return; + const definition = exported.PAPERCLIP_CHARACTER; + library.current = { create: runtime.createCharacter, definition, sequences: resolveOnboardingSequences(definition) }; + mount(phase.current); + if (players.current.base) setReady(true); + }).catch((error: unknown) => { + if (disposed) return; + console.warn("Onboarding character unavailable, showing the still portrait.", error); + fail(); + }); + return () => { disposed = true; clearTimers(); destroy("overlay"); destroy("base"); library.current = null; setReady(false); }; + // Mount once; later prop changes are transitions handled below. + }, []); + + // Refresh the hidden colored twin too, before a simultaneous wake. The gray + // canvas does not change color, but its twin must carry the new assignment. + useEffect(() => { + if (!ready || !library.current || renderedPalette.current === identity.paletteId) return; + try { mount(phase.current); } catch (error) { + console.warn("Onboarding character palette change failed, showing the still portrait.", error); + fail(); + } + }, [identity.paletteId, ready]); + + useEffect(() => { + if (!ready || !library.current || !players.current.base) return; + const next: Phase = awake ? "awake" : "asleep"; + if (next === phase.current) return; + try { + if (next === "awake") wake(); else mount("asleep"); + } catch (error) { + console.warn("Onboarding character transition failed, showing the still portrait.", error); + fail(); + } + }, [awake, ready]); + + const live = ready && !failed; + return ( +