feat(agents): persist personas and render cached avatar URLs

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-10 16:23:00 -05:00
parent 4042eb1c48
commit f5cd8de1b3
65 changed files with 49857 additions and 19 deletions

View File

@ -1308,3 +1308,9 @@ Networking behavior for this smoke script:
### GitHub identity for shared agents
See [execution GitHub identity](execution-github-identity.md) for the operation-time credential contract, continuation rules, runtime rollout, and acceptance-test requirements.
### Agent persona Storybook
See [agent-personas.md](agent-personas.md) for the dynamic avatar endpoint, cache,
and character stories. Set `PAPERCLIP_STORYBOOK_API_URL` to your isolated
Paperclip API URL when running Storybook. Avatar PNGs are generated on demand.

View File

@ -1561,3 +1561,10 @@ Export/import behavior in V1:
- import supports preview (dry-run) before apply
- import preview reports skill-policy and legacy-grant mappings before apply and rejects unknown policy schema versions
- GitHub imports warn on unpinned refs instead of blocking
## Agent visual identity
Agent appearances are stable, versioned ClipLab end-cap personas, separate from
behavioral instructions. Compact surfaces use on-demand cached PNG URLs; larger
placements may use a lazy live character. See [agent-personas.md](agent-personas.md)
for persistence, migration, rendering, and integration contracts.

View File

@ -543,3 +543,10 @@ Things Paperclip explicitly does **not** do:
7. **Atomic ownership.** Single assignee per task. Atomic checkout prevents conflicts.
8. **Progressive deployment.** Trivial to start local, straightforward to scale to hosted.
9. **Extensible core.** Clean boundaries so plugins can add capabilities (Adapters, knowledge base, revenue tracking) without modifying core.
## Agent visual identity
Agent appearances are stable, versioned ClipLab end-cap personas, separate from
behavioral instructions. Compact surfaces use on-demand cached PNG URLs; larger
placements may use a lazy live character. See [agent-personas.md](agent-personas.md)
for persistence, migration, rendering, and integration contracts.

166
doc/agent-personas.md Normal file
View File

@ -0,0 +1,166 @@
# Agent personas
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
backfilled with the same ID-based mapping used by legacy clients. Duplicating
an agent generates another assignment. Export/import preserves it.
## Rendering and URLs
`GET /api/agent-avatars/cap-v1/bubblegum-sky/rest.png?size=24&scale=2`
The endpoint is public preset artwork, contains no agent/company identifiers,
and works without the UI. `Agent.avatarUrl` is the 512px resting portrait URL.
Resolve relative URLs against the instance base URL for integrations.
Supported logical sizes: 16, 20, 24, 32, 40, 48, 64, 96, 128, 256, 512.
Density is 1 or 2. Poses: rest, idle, listening, thinking, working, success,
confused, sleepy, loading. Other inputs receive 400. Display size controls
face detail separately from raster dimensions: 24px at 2× remains eyes-only.
A cold request samples ClipLab's deterministic scene, exports SVG using the
same geometry and face code as the live renderer, and rasterizes it with sharp
in a worker thread. No Chromium, GPU, pre-rendering command, or per-agent asset
row is required. Two workers serve a bounded queue, coalesce identical requests
within the process, time out failed rendering, and stop after an idle interval.
The configured local-disk/S3 provider stores results in
`generated-agent-avatars/<version>/<palette>/<pose>-<size>-<scale>.png`.
This preset-only cache intentionally uses the storage provider directly;
company asset APIs retain their existing authorization boundary. Cache data
can be removed and regenerates on demand. Independent replicas can render the
same key safely; successful writes are complete objects. ETags hash PNG bytes.
A small `.png.json` sidecar stores the content digest and length; it is published
after the complete PNG. Warm requests stream stored PNG bytes without re-rendering
or buffering the image in the API process. Responses are immutable for one year. Render/storage failures return 503 with
Retry-After and no-store rather than caching a broken image.
cap-v1 is frozen: change the version when changing palette values, poses,
rendering, or dependencies in a way that changes pixels. Keep old versions
available. The SVG exporter approximates 3D gradients with a planar gradient;
front-facing identity portraits minimize the difference from WebGL.
## Components
- `AgentAvatar`: image only; pass the agent record or appearance and a semantic
size. No per-agent queries, live-renderer imports, or circle cropping.
- `AgentIdentity`: agent avatar and name. Human identities keep `Identity`.
- `AgentCharacter`: lazy live hero with state and optional tracking-region
props, plus an explicit `trackingScope="page"` for onboarding and agent headers. One live renderer per view; other instances keep their still image.
Reduced motion, offscreen content, renderer failure, and static states do
not run the animation loop. Pointer tracking defaults to its region and is off for touch.
Onboarding stores the eventual palette in its existing draft and presents gray
until verified connection/hiring succeeds. Reconnect never randomizes identity.
Names, explicit status badges, and status text remain authoritative.
## Development and verification
ClipLab provenance/licenses are under `packages/shared/src/cliplab/`.
Palette tokens originate in `ui/src/index.css`. After deliberately introducing
new versioned artwork, `node scripts/sync-agent-palette-tokens.mjs` synchronizes
the TS palette data; `--check` detects drift. This does not generate images.
Storybook: **Agents / Personas**. Run with
`PAPERCLIP_STORYBOOK_API_URL=http://localhost:<isolated-port> pnpm storybook`.
Static Storybook hosting must proxy `/api/agent-avatars` to an instance.
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
pnpm check:token-gates
pnpm build-storybook
```
The 500-avatar story must create zero WebGL contexts and load no live runtime.
Use fixed poses/times for screenshots and Linux for authoritative visual
baselines. Verify cold and warm URLs, reduced motion, reconnect, and saved
appearance after refresh alongside normal typecheck/test/build checks.
Linux visual/performance checks (against the built Storybook with the API proxy):
```sh
PAPERCLIP_PERSONA_STORYBOOK_URL=http://localhost:<storybook-port> \
pnpm exec playwright test --config tests/storybook-visual/agent-personas.config.ts
```
Use the Playwright 1.62.1 Noble image for authoritative Linux baselines. The suite
covers both themes, palette and size grids, every expression, fixed-pose SVG/WebGL
pixel comparisons, repeated mounts, context loss, delayed/failed images, and the
500-avatar no-WebGL/no-live-download/no-frame-loop contract. Baselines follow the
existing Storybook visual artifact workflow; they are not application assets.
## Acceptance record — 2026-09-10
Implemented in `codex/agent-personas`, based on `5cb4f061d`, with the original
checkout left unchanged. Verification used disposable embedded-Postgres data,
a local MinIO container, and Playwright's Linux Noble image.
| Check | Result |
| --- | --- |
| Repository typecheck and build | Passed |
| Token gates and palette-token synchronization | Passed |
| Storybook production build | Passed |
| Linux visual/performance suite | 30 passed; exact snapshot comparison passed |
| Static/live agreement | Rest at 16/24/48/256 logical pixels, both densities; all eight animated expression snapshots at 128px/2× |
| Avatar endpoint | Cold/warm requests, concurrency, deletion, retry, ETags and invalid parameters passed |
| Real local-disk and S3-compatible storage | Passed; persisted warm cache reused by a fresh service instance |
| Compiled API-only smoke | Passed with TypeScript stripping disabled, no UI and a native worker |
| Persistence and contracts | Creation, saved draft, SQL backfill, approvals, duplication, portability and revision restoration passed |
| UI/runtime lifecycle | Reduced motion, one live owner, scoped pointers, hidden/offscreen suspension, failures and disposal passed |
| Hands-on app | Stable rename/reload, fresh duplicate assignment, paused static portrait, matching task/list/configuration identities passed |
The broad `pnpm test:run` verification was completed in its groups/shards after
resource-contention retries. General server (8,347 tests), UI (5,619), CLI (484),
DB (133), and adapter/plugin source suites (2,235) passed. Remaining route suites
and the added persona/contract tests passed after the OpenAPI coverage update.
The queued-comment suite exposed a pre-existing fixture-cleanup failure: run
claims created dependent runtime rows, so swallowed foreign-key errors left the
`QUE` company behind. Its isolated-database cleanup now truncates the company
fixture graph with cascade; all 17 tests pass together. No production queued-comment
behavior changed. Verification completed across the repository runner's groups
and shards, with targeted reruns after fixes, rather than another monolithic run.
Actual provider sign-in was not completed: the disposable instance had no managed
sandbox available for sign-in. Onboarding gray/loading/success transitions were
covered in Storybook and the onboarding tests. The app is usable in local-trusted
mode; this standalone worktree has no managed issue identity or login handoff.
The reviewed Linux candidate archive is generated at
`tests/storybook-visual/baseline-review/snapshots.tgz`. It remains a local review
artifact; the existing baseline manifest was not repointed to an unpublished URL.
## Placement and sharpness refinement — 2026-09-10
The overview/configuration header owns the live character beside the agent name
and follows the pointer across the whole page; there is no second hero in the
overview body. The new-agent dialog and setup page
use a larger, padded character frame with page-wide mouse tracking. Other
placements retain region-scoped tracking. Touch, reduced motion, hidden views,
and unmount cleanup still disable tracking and frame scheduling.
Live canvases render at twice the display density (capped at 4×), with a 1024px
face texture and padded framing for rotations and expression props. This affects
only the live renderer: existing versioned PNG URLs retain their original pixels.
The Snapshot Agreement story provides explicit 1×/2× controls and labels the
actual PNG and WebGL pixel dimensions for a fair comparison.
`Agents / Personas / Full pages` includes the actual application shell and route
components for all agents, agent overview, task detail, dashboard, the new-agent
dialog, and the connection page. Fixtures stay in Storybook; these examples do
not read or mutate the running company's data. Dashboard activity rows now use
the same agent avatars as its active-agent and task placements.
Refinement verification: 38 Linux Playwright checks passed, including exact
comparison with the reviewed snapshots for all six full-page stories and
corner-pointer clipping checks at 1×/2× display density. The 500-avatar view
still loads no live renderer or WebGL contexts. All 18 targeted appearance,
component/runtime, and avatar endpoint tests passed, as did shared/UI typechecks,
token gates, UI build, and Storybook build. Hands-on inspection confirmed the
single animated header at `/PER/agents/persona-tester-renamed/overview` and the
larger onboarding character in the real route components. The repository-wide
checks above were not repeated for this UI refinement.

View File

@ -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",
"check:token-gates": "node scripts/check-token-gates.mjs && node scripts/sync-agent-palette-tokens.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",

View File

@ -0,0 +1,11 @@
ALTER TABLE "agents" ADD COLUMN IF NOT EXISTS "appearance" jsonb;
--> statement-breakpoint
WITH RECURSIVE appearance_hash AS (
SELECT id, id::text AS identity_text, 0 AS position, 0 AS hash FROM agents WHERE appearance IS NULL
UNION ALL
SELECT id, identity_text, position + 1, (hash * 31 + ascii(substr(identity_text, position + 1, 1))) % 17
FROM appearance_hash WHERE position < length(identity_text)
)
UPDATE agents SET appearance = jsonb_build_object('schemaVersion', 1, 'characterVersion', 'cap-v1',
'paletteId', (ARRAY['bubblegum-sky','pink-lemonade','orchid-peach','coral-mint','lime-lagoon','arctic-blue','solar-flare','violet-ember','deep-tide','coral-current','golden-hour','tangerine-cobalt','electric-grove','flamingo-jade','cherry-pop','turquoise-cherry','ultraviolet-tide'])[appearance_hash.hash + 1])
FROM appearance_hash WHERE agents.id = appearance_hash.id AND appearance_hash.position = length(appearance_hash.identity_text) AND agents.appearance IS NULL;

File diff suppressed because it is too large Load Diff

View File

@ -1891,6 +1891,13 @@
"when": 1788999440971,
"tag": "0271_woozy_silver_surfer",
"breakpoints": true
},
{
"idx": 272,
"version": "7",
"when": 1789075418387,
"tag": "0272_marvelous_madame_web",
"breakpoints": true
}
]
}

View File

@ -1,3 +1,4 @@
import type { AgentAppearance } from "@paperclipai/shared";
import {
type AnyPgColumn,
pgTable,
@ -21,6 +22,7 @@ export const agents = pgTable(
role: text("role").notNull().default("general"),
title: text("title"),
icon: text("icon"),
appearance: jsonb("appearance").$type<AgentAppearance>(),
status: text("status").notNull().default("idle"),
reportsTo: uuid("reports_to").references((): AnyPgColumn => agents.id),
capabilities: text("capabilities"),

View File

@ -40,16 +40,18 @@
"dist"
],
"scripts": {
"build": "tsc",
"build": "tsc && mkdir -p dist/cliplab && cp src/cliplab/LICENSE src/cliplab/PROVENANCE.md dist/cliplab/",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^4.4.3"
"zod": "^4.4.3",
"three": "0.185.1"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^7.0.2"
"typescript": "^7.0.2",
"@types/three": "0.185.4"
},
"engines": {
"node": ">=24.11.0"

View File

@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { AGENT_PALETTE_IDS, agentAppearanceSchema, appearanceForPalette, legacyAgentAppearance, randomAgentAppearance, resolveAgentAppearance, agentAvatarUrl } from "./agent-appearance.js";
import { renderAgentSvg } from "./cliplab/static.js";
describe("agent appearance", () => {
it("assigns only the 17 permanent cap palettes", () => {
for (let i = 0; i < 100; i++) expect(AGENT_PALETTE_IDS).toContain(randomAgentAppearance().paletteId);
expect(agentAppearanceSchema.safeParse({ schemaVersion: 1, characterVersion: "cap-v1", paletteId: "muted-dream" }).success).toBe(false);
});
it("preserves a saved appearance and resolves legacy IDs deterministically", () => {
const appearance = appearanceForPalette("deep-tide");
expect(resolveAgentAppearance(appearance, "different-id")).toEqual(appearance);
expect(resolveAgentAppearance(null, "agent-1")).toEqual(legacyAgentAppearance("agent-1"));
expect(legacyAgentAppearance("agent-1")).toEqual(legacyAgentAppearance("agent-1"));
expect(agentAvatarUrl(appearance, 24, 2)).toBe("/api/agent-avatars/cap-v1/deep-tide/rest.png?size=24&scale=2");
});
it("renders without a browser and preserves logical-size detail at high density", () => {
const appearance = appearanceForPalette("bubblegum-sky");
const small = renderAgentSvg(appearance, 16, 2);
expect(small).toContain('width="32"');
expect(small).not.toContain('id="agent-face-visible"');
const eyes = renderAgentSvg(appearance, 24, 2);
expect(eyes).toContain('width="48"');
expect(eyes).toContain('id="agent-face-visible"');
expect(eyes).not.toContain('id="agent-candle-light"');
expect(renderAgentSvg(appearance, 48, 1)).toContain('id="agent-candle-light"');
expect(renderAgentSvg(appearance, 24, 2)).toBe(eyes);
});
});

View File

@ -0,0 +1,52 @@
import { z } from "zod";
export const AGENT_PALETTE_IDS = ["bubblegum-sky", "pink-lemonade", "orchid-peach", "coral-mint", "lime-lagoon", "arctic-blue", "solar-flare", "violet-ember", "deep-tide", "coral-current", "golden-hour", "tangerine-cobalt", "electric-grove", "flamingo-jade", "cherry-pop", "turquoise-cherry", "ultraviolet-tide"] as const;
export type AgentPaletteId = typeof AGENT_PALETTE_IDS[number];
export type CharacterPaletteId = AgentPaletteId | "muted-dream";
export const AGENT_AVATAR_SIZES = [16, 20, 24, 32, 40, 48, 64, 96, 128, 256, 512] as const;
export type AgentAvatarSize = typeof AGENT_AVATAR_SIZES[number];
export const CHARACTER_STATES = ["rest", "idle", "listening", "thinking", "working", "success", "confused", "sleepy", "loading"] as const;
export type CharacterState = typeof CHARACTER_STATES[number];
export const agentAppearanceSchema = z.object({
schemaVersion: z.literal(1),
characterVersion: z.literal("cap-v1"),
paletteId: z.enum(AGENT_PALETTE_IDS),
}).strict();
export type AgentAppearance = z.infer<typeof agentAppearanceSchema>;
export function appearanceForPalette(paletteId: AgentPaletteId): AgentAppearance {
return { schemaVersion: 1, characterVersion: "cap-v1", paletteId };
}
/** A persisted choice, never randomize while rendering. */
export function randomAgentAppearance(): AgentAppearance {
const bytes = new Uint32Array(1);
// Rejection sampling avoids modulo bias.
const limit = Math.floor(0x100000000 / AGENT_PALETTE_IDS.length) * AGENT_PALETTE_IDS.length;
do { globalThis.crypto.getRandomValues(bytes); } while (bytes[0] >= limit);
return appearanceForPalette(AGENT_PALETTE_IDS[bytes[0] % AGENT_PALETTE_IDS.length]);
}
/** Must match the migration: rolling base-31 hash, modulo 17 at every step. */
export function legacyAgentAppearance(id: string): AgentAppearance {
let hash = 0;
for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) % AGENT_PALETTE_IDS.length;
return appearanceForPalette(AGENT_PALETTE_IDS[hash]);
}
export function resolveAgentAppearance(appearance: unknown, id = "agent"): AgentAppearance {
const parsed = agentAppearanceSchema.safeParse(appearance);
return parsed.success ? parsed.data : legacyAgentAppearance(id);
}
export function agentAvatarUrl(appearance: AgentAppearance, size: AgentAvatarSize = 512, scale: 1 | 2 = 1, pose: CharacterState = "rest", muted = false): string {
return `/api/agent-avatars/${appearance.characterVersion}/${muted ? "muted-dream" : appearance.paletteId}/${pose}.png?size=${size}&scale=${scale}`;
}
export function characterStateForAgent(status: string): CharacterState {
if (status === "running") return "working";
if (status === "error") return "confused";
if (status === "paused" || status === "terminated" || status === "pending_approval") return "rest";
return "idle";
}
/** Hydrate compact agent projections without an additional per-agent request. */
export function withAgentAppearance<T extends { id: string; appearance?: AgentAppearance | null }>(agent: T) {
const appearance = resolveAgentAppearance(agent.appearance, agent.id);
return { ...agent, appearance, avatarUrl: agentAvatarUrl(appearance) };
}

View File

@ -0,0 +1,22 @@
MIT License
Copyright (c) 2026 Jérémy Perret (bloub, the project this is forked from)
Copyright (c) 2026 Tonio (Cliplab, modifications)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,17 @@
# ClipLab cap-v1
Source: https://github.com/tonio-alucema/cliplab
Commit: a050f7246738db1d0763e286862747633293e2af
License: MIT (see LICENSE). Three.js is MIT licensed.
Paperclip adaptations: ESM extensions, optional graphics backend for Node SVG
snapshots, region-scoped runtime input (with explicit page scope for onboarding),
suspended frame scheduling, and supersampled live textures/canvases. Live-only
framing leaves the versioned static snapshot geometry unchanged. The Vue
studio and media encoders are not included. Keep this version immutable after
release; new artwork or rasterization changes require a new character version.
Palette source: https://cliptoon-color-library.vercel.app/
The 17 assignable colors and Muted dream are frozen as cap-v1 tokens in
ui/src/index.css. Generated palette data is checked by
scripts/sync-agent-palette-tokens.mjs --check.

View File

@ -0,0 +1,18 @@
import type { AgentAppearance, CharacterPaletteId, CharacterState } from "../agent-appearance.js";
import { defaultProject, definitionOf, sampleDefinition, BASE_POSE, type Definition, type Sample } from "./model.js";
import { CAP_V1_COLORS } from "./palette-tokens.js";
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);
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; }
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 };
return sampleDefinition(definition, animationId(state), 0.6);
}

View File

@ -0,0 +1,205 @@
// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE.
import type { Detail, FaceLayer, Pose } from './model.js'
import { irisOffset, type Gaze } from './gaze.js'
export type Point = { x: number; y: number }
const COUNT = 128, TAU = Math.PI * 2
const pt = (x: number, y: number): Point => ({ x, y })
const distance = (a: Point, b: Point) => Math.hypot(b.x - a.x, b.y - a.y)
const unit = (a: Point) => { const n = Math.hypot(a.x, a.y) || 1; return pt(a.x / n, a.y / n) }
const arc = (x: number, y: number, rx: number, ry: number, from = 0, to = TAU, count = 64) => Array.from({ length: count + 1 }, (_, i) => { const a = from + (to - from) * i / count; return pt(x + Math.cos(a) * rx, y + Math.sin(a) * ry) })
const cubic = (a: Point, b: Point, c: Point, d: Point, n = 32) => Array.from({ length: n + 1 }, (_, i) => {
const t = i / n, s = 1 - t
return pt(s ** 3 * a.x + 3 * s ** 2 * t * b.x + 3 * s * t ** 2 * c.x + t ** 3 * d.x, s ** 3 * a.y + 3 * s ** 2 * t * b.y + 3 * s * t ** 2 * c.y + t ** 3 * d.y)
})
const quad = (a: Point, b: Point, c: Point) => cubic(a, pt(a.x + (b.x - a.x) * 2 / 3, a.y + (b.y - a.y) * 2 / 3), pt(c.x + (b.x - c.x) * 2 / 3, c.y + (b.y - c.y) * 2 / 3), c)
const area = (p: Point[]) => p.reduce((sum, a, i) => { const b = p[(i + 1) % p.length]!; return sum + a.x * b.y - b.x * a.y }, 0)
function clean(points: Point[]) {
const p = points.filter((point, i) => i === 0 || distance(point, points[i - 1]!) > .00001)
if (p.length > 1 && distance(p[0]!, p.at(-1)!) < .00001) p.pop()
return p
}
/** Stable winding, anchor and perimeter correspondence; never align against a live blend. */
export function contour(points: Point[]): Point[] {
let p = clean(points)
if (p.length < 2) return Array.from({ length: COUNT }, () => ({ ...(p[0] ?? pt(0, 0)) }))
if (area(p) < 0) p.reverse()
let anchor = 0
for (let i = 1; i < p.length; i++) if (p[i]!.x < p[anchor]!.x - .00001 || (Math.abs(p[i]!.x - p[anchor]!.x) < .00001 && p[i]!.y < p[anchor]!.y)) anchor = i
p = [...p.slice(anchor), ...p.slice(0, anchor)]
const lengths = p.map((a, i) => distance(a, p[(i + 1) % p.length]!)), total = lengths.reduce((a, b) => a + b, 0)
let segment = 0, traversed = 0
return Array.from({ length: COUNT }, (_, i) => {
const target = total * i / COUNT
while (segment < p.length - 1 && traversed + lengths[segment]! < target) traversed += lengths[segment++]!
const a = p[segment]!, b = p[(segment + 1) % p.length]!, t = (target - traversed) / (lengths[segment] || 1)
return pt(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
})
}
function ribbon(points: Point[], width: number): Point[] {
const p = clean(points), h = width / 2
const normals = p.slice(1).map((b, i) => { const a = p[i]!; return unit(pt(a.y - b.y, b.x - a.x)) })
if (!normals.length) return arc(p[0]!.x, p[0]!.y, h, h)
const side = (sign: number) => p.flatMap((a, i) => {
const before = normals[Math.max(0, i - 1)]!, after = normals[Math.min(normals.length - 1, i)]!
const cross = before.x * after.y - before.y * after.x
if (i > 0 && i < p.length - 1 && cross * sign < -.0001) {
const from = Math.atan2(before.y * sign, before.x * sign)
const delta = Math.atan2(cross, before.x * after.x + before.y * after.y)
return arc(a.x, a.y, h, h, from, from + delta, Math.max(2, Math.ceil(Math.abs(delta) * 10)))
}
const n = unit(pt(before.x + after.x, before.y + after.y)), reach = h / Math.max(.35, n.x * after.x + n.y * after.y)
return [pt(a.x + n.x * reach * sign, a.y + n.y * reach * sign)]
})
const end = p.at(-1)!, first = p[0]!, endAngle = Math.atan2(normals.at(-1)!.y, normals.at(-1)!.x), startAngle = Math.atan2(normals[0]!.y, normals[0]!.x)
return [...side(1), ...arc(end.x, end.y, h, h, endAngle, endAngle - Math.PI, 16), ...side(-1).reverse(), ...arc(first.x, first.y, h, h, startAngle - Math.PI, startAngle - TAU, 16)]
}
function expand(points: Point[], amount: number) {
const p = clean(points); if (area(p) < 0) p.reverse()
return p.flatMap((a, i) => {
const before = p[(i - 1 + p.length) % p.length]!, after = p[(i + 1) % p.length]!
const u = unit(pt(a.y - before.y, before.x - a.x)), v = unit(pt(after.y - a.y, a.x - after.x)), n = unit(pt(u.x + v.x, u.y + v.y))
const cross = u.x * v.y - u.y * v.x
if (cross > .0001) {
const from = Math.atan2(u.y, u.x), delta = Math.atan2(cross, u.x * v.x + u.y * v.y)
return arc(a.x, a.y, amount, amount, from, from + delta, Math.max(2, Math.ceil(delta * 10)))
}
const reach = amount / Math.max(.5, n.x * v.x + n.y * v.y)
return [pt(a.x + n.x * reach, a.y + n.y * reach)]
})
}
export const traitWeight = (layers: FaceLayer[], predicate: (pose: FaceLayer['traits']) => boolean) => layers.reduce((n, layer) => n + (predicate(layer.traits) ? layer.weight : 0), 0)
export function blendContours(contours: Point[][], layers: FaceLayer[]): Point[] {
return Array.from({ length: COUNT }, (_, i) => contours.reduce((point, shape, j) => pt(point.x + shape[i]!.x * layers[j]!.weight, point.y + shape[i]!.y * layers[j]!.weight), pt(0, 0)))
}
function trace(ctx: CanvasRenderingContext2D, shape: Point[]) {
ctx.beginPath(); ctx.moveTo(shape[0]!.x, shape[0]!.y)
for (const point of shape.slice(1)) ctx.lineTo(point.x, point.y)
ctx.closePath()
}
export function isLineEye(pose: Pick<Pose, 'eye' | 'mouth'>, side: number, detail: Detail) {
return ['closed', 'arc-up', 'arc-down', 'squint'].includes(pose.eye) || (pose.eye === 'wink' && side > 0) || (detail === 'eyes' && ['open', 'grin'].includes(pose.mouth) && pose.eye === 'dot')
}
export function eyeContour(pose: Pose, r: number, side: number, blink: number, detail: Detail): Point[] {
const stroke = Math.max(13, r * .43)
if ((pose.faceSet === 'set-2' && pose.eye === 'wink' && side > 0) || pose.eye === 'squint') {
const sign = pose.eye === 'wink' ? -1 : side
return contour(ribbon([pt(-r * sign * .7, -r * .8), pt(r * sign * .6, 0), pt(-r * sign * .7, r * .8)], stroke))
}
if (isLineEye(pose, side, detail)) {
const happy = ['open', 'grin', 'smile', 'u-smile'].includes(pose.mouth)
const up = pose.eye === 'arc-up' || (pose.eye !== 'arc-down' && happy)
return contour(ribbon(arc(0, 0, r, r, up ? Math.PI : 0, up ? TAU : Math.PI), stroke))
}
const h = Math.max(.12, 1 - blink) * pose.eyeHeight * (pose.eye === 'soft' ? .65 : 1)
let shape: Point[]
if (pose.eye === 'star') shape = Array.from({ length: 10 }, (_, i) => { const a = i / 10 * TAU - Math.PI / 2, size = r * 1.2 * (i % 2 ? .46 : 1); return pt(Math.cos(a) * size, Math.sin(a) * size) })
else if (pose.eye === 'heart') shape = [...cubic(pt(0, r), pt(-r * 2, -r * .1), pt(-r, -r * 1.5), pt(0, -r * .55)), ...cubic(pt(0, -r * .55), pt(r, -r * 1.5), pt(r * 2, -r * .1), pt(0, r))]
else if (pose.eye === 'half-lidded') shape = arc(0, 0, r, r, 0, Math.PI)
else { const size = r * (pose.eye === 'pupil' && detail === 'full' ? 1.5 : 1); shape = arc(0, 0, size, size) }
const open = contour(shape.map(p => pt(p.x, p.y * h)))
const closing = Math.max(0, Math.min(1, (blink - .5) / .5))
if (!closing) return open
const closed = contour(ribbon([pt(-r, 0), pt(r, 0)], stroke))
return open.map((p, i) => pt(p.x + (closed[i]!.x - p.x) * closing, p.y + (closed[i]!.y - p.y) * closing))
}
export function mouthGeometry(pose: Pose): { outline: Point[]; opening: Point[]; width: number; height: number } {
const broad = ['open', 'grin', 'cry'].includes(pose.mouth), w = (broad ? 124 : pose.mouth === 'oh' ? 51 : 61) * pose.mouthWidth
const h = (broad ? 48 : 22) + 72 * pose.mouthOpen, stroke = 17 * pose.mouthStroke
let path: Point[], filled = false
if (pose.mouth === 'smile') path = arc(0, -10, w, w, Math.PI * .18, Math.PI * .82)
else if (pose.mouth === 'u-smile') path = arc(0, -8, w * .56, w * .56, 0, Math.PI)
else if (pose.mouth === 'frown') path = arc(0, 39, w, w, Math.PI * 1.2, Math.PI * 1.8)
else if (pose.mouth === 'line' || pose.mouth === 'sleep') path = [pt(-w * .58, 0), pt(w * .58, 0)]
else if (pose.mouth === 'kiss') path = [...cubic(pt(-w * .25, -21), pt(w * .48, -34), pt(w * .53, -2), pt(0, 0)), ...cubic(pt(0, 0), pt(w * .53, 2), pt(w * .48, 34), pt(-w * .25, 21))]
else if (pose.mouth === 'wave') path = cubic(pt(-w, 4), pt(-w * .35, -25), pt(w * .35, 25), pt(w, -4))
else if (pose.mouth === 'tongue-out') path = quad(pt(-w, -9), pt(0, -2), pt(w, -9))
else {
filled = true
if (pose.mouth === 'oh') { const r = w * (.55 + .3 * pose.mouthOpen); path = arc(0, 14, r, r) }
else if (pose.mouth === 'cry') path = [...cubic(pt(-w, 30), pt(-w * 1.1, -h), pt(w * 1.1, -h), pt(w, 30)), ...quad(pt(w, 30), pt(w, 44), pt(w * .76, 38)), ...quad(pt(w * .76, 38), pt(0, 24), pt(-w * .76, 38)), ...quad(pt(-w * .76, 38), pt(-w, 44), pt(-w, 30))].map(p => pt(p.x, p.y + 36 / (4 / 3)))
else { const tilt = pose.mouth === 'grin' ? 24 : 0; path = [...quad(pt(-w, -11), pt(0, 5), pt(w, -11 - tilt)), ...cubic(pt(w, -11 - tilt), pt(w * 1.02, h), pt(-w * 1.02, h), pt(-w, -11))] }
}
return { outline: contour(filled ? expand(path, stroke / 2) : ribbon(path, stroke)), opening: filled ? contour(path) : Array.from({ length: COUNT }, () => pt(0, 0)), width: w, height: h }
}
function colorMix(colors: { color: string; weight: number }[]) {
const value = [0, 0, 0]
for (const { color, weight } of colors) for (let i = 0; i < 3; i++) value[i]! += parseInt(color.slice(1 + i * 2, 3 + i * 2), 16) * weight
return `rgb(${value.map(v => Math.round(v)).join(',')})`
}
export function drawMorphEye(ctx: CanvasRenderingContext2D, pose: Pose, layers: FaceLayer[], r: number, side: number, blink: number, detail: Detail, ink: string, iris: boolean, gaze: Gaze) {
const shape = blendContours(layers.map(layer => eyeContour({ ...pose, ...layer.traits }, r, side, blink, detail)), layers)
const filled = (traits: FaceLayer['traits']) => !isLineEye(traits, side, detail)
const cheeks = traitWeight(layers, t => t.cheeks && filled(t))
ctx.save()
if (cheeks > 0) {
const h = Math.max(.12, 1 - blink) * pose.eyeHeight * (1 - .35 * traitWeight(layers, t => t.eye === 'soft')), cut = r * .82 * Math.sqrt(cheeks)
ctx.beginPath(); ctx.rect(-r * 3, -r * 3, r * 6, r * 6); ctx.moveTo(cut, r * 1.13 * h); ctx.ellipse(0, r * 1.13 * h, cut, cut * h, 0, 0, TAU); ctx.clip('evenodd')
}
ctx.fillStyle = colorMix(layers.map(({ traits, weight }) => ({ weight, color: detail === 'full' && traits.eye === 'pupil' ? '#fffef9' : detail === 'full' && traits.eye === 'heart' && traits.faceSet === 'set-2' ? '#ff3f58' : ink })))
trace(ctx, shape); ctx.fill(); ctx.clip()
ctx.scale(1, Math.max(.12, 1 - blink) * pose.eyeHeight * (1 - .35 * traitWeight(layers, t => t.eye === 'soft')))
const pupil = detail === 'full' ? traitWeight(layers, t => t.eye === 'pupil') : 0
if (pupil > 0) {
ctx.globalAlpha = pupil * (1 - blink); ctx.fillStyle = ink
ctx.beginPath(); ctx.arc(gaze.x * r * 1.5 * .52, -gaze.y * r * 1.5 * .52, r * .6, 0, TAU); ctx.fill()
}
const visible = iris && detail === 'full' ? traitWeight(layers, t => filled(t) && t.eye !== 'pupil' && t.eye !== 'half-lidded') : 0
if (visible > 0) {
const dot = irisOffset(r, gaze, 0, cheeks)
ctx.globalAlpha = visible * (1 - blink); ctx.fillStyle = '#ffffff'
ctx.beginPath(); ctx.arc(dot.x, dot.y, r * .28, 0, TAU); ctx.fill()
}
ctx.restore()
}
/** Find the lower contour at the drool's right-side attachment point. Using
* the rendered outline keeps it attached throughout mouth-shape morphs. */
export function droolAnchor(outline: Point[], width: number): Point {
const x = Math.min(width * .35, Math.max(...outline.map(p => p.x)) * .75)
let bottom = -Infinity
for (let i = 0; i < outline.length; i++) {
const a = outline[i]!, b = outline[(i + 1) % outline.length]!
if ((a.x <= x && b.x >= x) || (b.x <= x && a.x >= x)) {
const y = Math.abs(b.x - a.x) < 1e-8 ? Math.max(a.y, b.y) : a.y + (b.y - a.y) * (x - a.x) / (b.x - a.x)
bottom = Math.max(bottom, y)
}
}
// The round cap overlaps the lip by two units so there is no visible gap.
return { x, y: (Number.isFinite(bottom) ? bottom : 0) + 6 }
}
export function drawDrool(ctx: CanvasRenderingContext2D, anchor: Point, amount = 1) {
ctx.save(); ctx.globalAlpha = amount; ctx.strokeStyle = '#fffef5'; ctx.lineWidth = 16
ctx.beginPath(); ctx.moveTo(anchor.x, anchor.y); ctx.lineTo(anchor.x + 3, anchor.y + 34 * amount); ctx.stroke()
ctx.fillStyle = '#fffef5'; ctx.beginPath(); ctx.arc(anchor.x + 3, anchor.y + 38 * amount, 8 * amount, 0, TAU); ctx.fill(); ctx.restore()
}
export function drawMorphMouth(ctx: CanvasRenderingContext2D, pose: Pose, layers: FaceLayer[], ink: string) {
const geometry = layers.map(layer => mouthGeometry({ ...pose, ...layer.traits }))
const outline = blendContours(geometry.map(g => g.outline), layers), opening = blendContours(geometry.map(g => g.opening), layers)
const w = geometry.reduce((sum, g, i) => sum + g.width * layers[i]!.weight, 0), h = geometry.reduce((sum, g, i) => sum + g.height * layers[i]!.weight, 0)
ctx.fillStyle = ink; trace(ctx, outline); ctx.fill()
ctx.save(); trace(ctx, outline); ctx.clip(); trace(ctx, opening); ctx.clip()
const tongue = traitWeight(layers, t => t.tongue), teeth = traitWeight(layers, t => t.teeth)
ctx.translate(0, 27 * traitWeight(layers, t => t.mouth === 'cry'))
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()
const out = traitWeight(layers, t => t.mouth === 'tongue-out')
if (out > 0) {
const w = 61 * pose.mouthWidth
ctx.save(); ctx.globalAlpha = out; ctx.fillStyle = '#ff526c'; ctx.beginPath(); ctx.moveTo(-w * .6 * out, 5); ctx.lineTo(w * .6 * out, 5); ctx.bezierCurveTo(w * .88 * out, (85 * pose.mouthOpen + 28) * out, -w * .88 * out, (85 * pose.mouthOpen + 28) * out, -w * .6 * out, 5); ctx.fill(); ctx.restore()
}
const drool = traitWeight(layers, t => t.drool)
if (drool > 0) drawDrool(ctx, droolAnchor(outline, w), drool)
}
export function drawMorphBrow(ctx: CanvasRenderingContext2D, layers: FaceLayer[], r: number, side: number, ink: string) {
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))
})
ctx.fillStyle = ink; trace(ctx, blendContours(shapes, layers)); ctx.fill()
}

View File

@ -0,0 +1,65 @@
// Vendored from ClipLab a050f724; 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 }
export const centeredGaze = (): Gaze => ({ x: 0, y: 0 })
export const inactivePointer = (): PointerLook => ({ x: 0, y: 0, weight: 0 })
/** Geometric canvas coordinates retain the target's position, even outside the canvas. */
export function pointerLook(pointer: { clientX: number; clientY: number }, bounds: { left: number; top: number; width: number; height: number }): PointerLook {
return { x: (pointer.clientX - bounds.left) / Math.max(1, bounds.width) * 2 - 1, y: 1 - (pointer.clientY - bounds.top) / Math.max(1, bounds.height) * 2, weight: 1 }
}
export function easePointer(current: PointerLook, target: PointerLook, seconds: number, immediate = false): PointerLook {
return { ...easeGaze(current, target, seconds, immediate), weight: easeGaze({ x: current.weight, y: 0 }, { x: target.weight, y: 0 }, seconds, immediate).x }
}
/** Solve in the projected eye's own axes, then gently saturate travel with distance. */
export function gazeAtPoint(pointer: Gaze, center: Gaze, right: Gaze, up: Gaze): Gaze {
const rx = right.x - center.x, ry = right.y - center.y, ux = up.x - center.x, uy = up.y - center.y
const determinant = rx * uy - ry * ux
if (Math.abs(determinant) < 1e-10) return centeredGaze()
const dx = pointer.x - center.x, dy = pointer.y - center.y
const x = (dx * uy - dy * ux) / determinant, y = (rx * dy - ry * dx) / determinant
const distance = Math.hypot(x, y, 1.15)
return { x: x / distance, y: y / distance }
}
export function localGaze(gaze: Gaze, rotation: number): Gaze {
const angle = rotation * Math.PI / 180
return clampGaze({ x: gaze.x * Math.cos(angle) - gaze.y * Math.sin(angle), y: gaze.x * Math.sin(angle) + gaze.y * Math.cos(angle) })
}
export function clampGaze({ x, y }: Gaze): Gaze {
x = Number.isFinite(x) ? x : 0; y = Number.isFinite(y) ? y : 0
const length = Math.max(1, Math.hypot(x, y))
return { x: x / length, y: y / length }
}
export function pointerGaze(pointer: { clientX: number; clientY: number }, bounds: { left: number; top: number; width: number; height: number }): Gaze {
return clampGaze({
x: (pointer.clientX - bounds.left - bounds.width / 2) / Math.max(80, bounds.width / 2),
y: -(pointer.clientY - bounds.top - bounds.height / 2) / Math.max(80, bounds.height / 2)
})
}
/** Time-based easing, shared by the studio and paused or playing app characters. */
export function easeGaze(current: Gaze, target: Gaze, seconds: number, immediate = false): Gaze {
const amount = immediate ? 1 : 1 - Math.exp(-Math.max(0, seconds) * 16)
const next = { x: current.x + (target.x - current.x) * amount, y: current.y + (target.y - current.y) * amount }
return Math.hypot(next.x - target.x, next.y - target.y) < .0005 ? { ...target } : next
}
/** Keep the complete white dot inside the eye, with a small margin at every angle. */
export function irisOffset(radius: number, gaze: Gaze, eyeRotation = 0, cheeks: boolean | number = false): Gaze {
const look = clampGaze(gaze), angle = eyeRotation * Math.PI / 180
const x = look.x * radius * .62, y = -look.y * radius * .62
const dot = { x: x * Math.cos(angle) + y * Math.sin(angle), y: -x * Math.sin(angle) + y * Math.cos(angle) }
// Stay above the cheek's circular cutout, including the dot radius and a small gap.
if (cheeks) {
const amount = typeof cheeks === 'number' ? Math.max(0, Math.min(1, cheeks)) : 1
const clearance = radius * (.82 * Math.sqrt(amount) + .305)
if (Math.abs(dot.x) < clearance) dot.y = Math.min(dot.y, radius * 1.13 - Math.sqrt(clearance ** 2 - dot.x ** 2))
}
return dot
}

View File

@ -0,0 +1,323 @@
// Vendored from ClipLab a050f724; 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'
export type Prop = 'none' | 'zzz' | 'sparkle' | 'heart' | 'question' | 'sweat' | 'crown'
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
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
mouthWidth: number; mouthOpen: number; faceScale: number; faceY: number
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 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 }
export interface Character {
id: string; name: string; shape: Shape; color: string; color2: string; gradient: boolean
gradientAngle: number; toon: boolean; trueFront: boolean; lockPosition: boolean; eyeColor: string; iris: boolean
elevated: boolean; elevation: number; shadow: boolean
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 type FaceTraits = Pick<Pose, 'eye' | 'mouth' | 'faceSet' | 'brows' | 'cheeks' | 'tongue' | 'teeth' | 'drool'>
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[] }
export const EYES: Eye[] = ['dot', 'soft', 'closed', 'wink', 'star', 'heart', 'squint', 'wide', 'arc-up', 'arc-down', 'half-lidded', 'pupil']
export const MOUTHS: Mouth[] = ['smile', 'open', 'line', 'frown', 'oh', 'wave', 'sleep', 'grin', 'cry', 'u-smile', 'kiss', 'tongue-out']
export const PROPS: Prop[] = ['none', 'zzz', 'sparkle', 'heart', 'question', 'sweat', 'crown']
export const SHAPES: { id: Shape; name: string; ratio: string }[] = [
{ id: 'capsule', name: 'Capsule', ratio: '1:2' }, { id: 'cap', name: 'End cap', ratio: '1:1' }, { id: 'sphere', name: 'Circle', ratio: '1:1' }
]
export const PALETTES = [
['#ff986d', '#ffd092'], ['#81d4c1', '#c6efd2'], ['#a79ce8', '#d8cafa'],
['#f0ce58', '#fff1a5'], ['#ee98b2', '#ffd2da'], ['#72b6de', '#b9e4f5']
]
export function detailAt(size: number): Detail { return size <= 16 ? 'body' : size <= 24 ? 'eyes' : 'full' }
export function uid(prefix = 'item') { return `${prefix}-${crypto.randomUUID().slice(0, 8)}` }
export function clone<T>(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,
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,
tongue: false, teeth: false, drool: false, cheeks: false, tears: false, mouthStroke: 1
}
const p = (v: Partial<Pose> = {}): Pose => ({ ...BASE_POSE, ...v })
const b = (id: string, name: string, duration: number, pose: Partial<Pose>): Beat => ({ id, name, duration, pose: p(pose) })
export function defaultExpressions(): Expression[] {
return [
{ id: 'idle', name: 'Idle', description: 'A little breath. A quiet smile.', beats: [
b('idle-1', 'Settle', 1.8, {}), b('idle-2', 'Look around', 1.4, { gazeX: .22, rotationZ: -3, rotationY: 5 }), b('idle-3', 'Return', 1.8, {})
] },
{ id: 'listening', name: 'Listening', description: 'Present, curious, and all ears.', beats: [
b('listen-1', 'Notice', 1.2, { eye: 'wide', mouth: 'line', eyeSize: 1.06, rotationX: -4 }), b('listen-2', 'Lean in', 1.5, { mouth: 'smile', eyeHeight: 1.13, rotationZ: 6, rotationX: -7 }), b('listen-3', 'Nod', 1.1, { mouth: 'smile', rotationX: 5, rotationZ: 3 })
] },
{ id: 'thinking', name: 'Thinking', description: 'A glance up. Something is taking shape.', beats: [
b('think-1', 'Wonder', 1.5, { mouth: 'oh', mouthOpen: .23, mouthWidth: .6, gazeX: .6, gazeY: .55, rotationZ: -7, prop: 'question' }), b('think-2', 'Consider', 1.6, { eye: 'soft', mouth: 'wave', gazeX: -.35, gazeY: .2, rotationY: -8, rotationZ: 4 }), b('think-3', 'Almost', 1.2, { mouth: 'smile', gazeY: .25, rotationZ: -3 })
] },
{ id: 'working', name: 'Working', description: 'Small, focused, steady movements.', beats: [
b('work-1', 'Focus left', 1.0, { eye: 'soft', mouth: 'line', mouthWidth: .65, gazeX: -.5, gazeY: -.3, rotationY: -7, rotationX: 4 }), b('work-2', 'Focus right', 1.0, { eye: 'soft', mouth: 'line', mouthWidth: .65, gazeX: .5, gazeY: -.3, rotationY: 7, rotationX: 4 }), b('work-3', 'Check', 1.4, { mouth: 'smile', rotationX: -3 })
] },
{ id: 'happy', name: 'Happy', description: 'A small open smile says it all.', beats: [
b('happy-1', 'Light up', .9, { mouth: 'open', mouthOpen: .62, rotationZ: -5, squash: 1.025 }), b('happy-2', 'Delight', 1.3, { eye: 'arc-up', mouth: 'grin', mouthOpen: .8, prop: 'sparkle', rotationZ: 5, squash: .97 }), b('happy-3', 'Glow', 1.5, { mouth: 'open', mouthOpen: .5, rotationZ: -2 })
] },
{ id: 'confused', name: 'Confused', description: 'A little uneven. A second look.', beats: [
b('confused-1', 'Wait', 1.2, { eye: 'soft', leftScale: .7, rightScale: 1.1, mouth: 'wave', rotationZ: 10, prop: 'question' }), b('confused-2', 'Really?', 1.4, { leftScale: 1.1, rightScale: .7, mouth: 'oh', mouthOpen: .25, rotationZ: -9, gazeX: -.2 }), b('confused-3', 'Hmm', 1.3, { eye: 'soft', mouth: 'frown', mouthWidth: .75, rotationZ: 4 })
] },
{ id: 'sleepy', name: 'Sleepy', description: 'Slow breathing, drifting sleep marks.', beats: [
b('sleep-1', 'Doze', 2, { eye: 'arc-down', mouth: 'sleep', prop: 'zzz', drool: true, rotationZ: -6, rotationX: 4, squash: .97 }), b('sleep-2', 'Breathe in', 2, { eye: 'arc-down', mouth: 'oh', mouthOpen: .25, mouthWidth: .65, prop: 'zzz', drool: true, rotationZ: -3, squash: 1.025 }), b('sleep-3', 'Breathe out', 2, { eye: 'arc-down', mouth: 'sleep', prop: 'zzz', drool: true, rotationZ: -7, squash: .98 })
] },
{ id: 'sad', name: 'Sad', description: 'A round frown, a little lower.', beats: [
b('sad-1', 'Low', 1.6, { mouth: 'frown', rotationZ: -3 }), b('sad-2', 'Sigh', 1.8, { eye: 'arc-down', mouth: 'frown', rotationX: 4 }), b('sad-3', 'Look up', 1.6, { mouth: 'frown', gazeY: .2 })
] },
{ id: 'tearful', name: 'Tearful', description: 'A trembling mouth and falling tears.', beats: [
b('tear-1', 'Well up', 1.5, { mouth: 'cry', tears: true, mouthOpen: .6, rotationZ: -3 }), b('tear-2', 'Let go', 1.8, { mouth: 'cry', tears: true, mouthOpen: .85, eye: 'arc-down', rotationZ: 3 }), b('tear-3', 'Breathe', 1.8, { mouth: 'cry', tears: true, mouthOpen: .5 })
] },
{ id: 'playful', name: 'Playful', description: 'A wink, a grin, a little mischief.', beats: [
b('play-1', 'Peek', 1.1, { mouth: 'open', tongue: true, rotationY: -10, rotationZ: -8 }), b('play-2', 'Wink', 1.2, { eye: 'squint', mouth: 'u-smile', tongue: true, mouthOpen: .7, rotationZ: 9, prop: 'sparkle' }), b('play-3', 'Grin', 1.3, { mouth: 'grin', cheeks: true, teeth: true, mouthOpen: .45, rotationZ: -3 })
] },
{ id: 'loading', name: 'Loading', description: 'Focused Working gestures with a gently eased gradient turn.', poseExpressionId: 'working', beats: [
{ ...b('loading-rotate', 'Rotate', 2, {}), gradientAction: 'rotate' },
{ ...b('loading-hold', 'Hold', .5, {}), gradientAction: 'hold' }
] }
]
}
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,
shadow: true, motion: .45, speed: 1, blink: true, blinkInterval: 4.2, followCursor: false, followRotation: false
}
const expressions = defaultExpressions()
const sequences: Record<string, [string, number][]> = {
listening: [['listening', 3.8], ['idle', 1.6]],
thinking: [['thinking', 4.3], ['working', 1.8]],
working: [['working', 6.8], ['thinking', 2.2]],
happy: [['happy', 3.7], ['playful', 2.4], ['idle', 1.8]],
confused: [['confused', 3.9], ['thinking', 2.1]],
playful: [['playful', 3.6], ['happy', 2.2]]
}
return {
version: 1, name: 'My character studio', defaultsRevision: 2,
characters: [base, { ...base, id: 'pip', name: 'Pip', shape: 'cap', color: '#81d4c1', color2: '#c6efd2' }, { ...base, id: 'lumi', name: 'Lumi', shape: 'sphere', color: '#a79ce8', color2: '#d8cafa' }],
expressions,
animations: expressions.map((e) => ({
id: e.id, name: e.id === 'happy' ? 'Success' : e.name, loop: true,
steps: (sequences[e.id] ?? [[e.id, expressionDuration(e)] as [string, number]]).map(([expressionId, duration], i) => ({ id: `${e.id}-step-${i + 1}`, expressionId, duration }))
}))
}
}
const safeDuration = (value: number) => Number.isFinite(value) ? Math.max(.2, value) : 1
export function expressionDuration(expression: Expression) { return expression.beats.reduce((n, beat) => n + safeDuration(beat.duration), 0) }
export function animationDuration(animation: Animation) { return animation.steps.reduce((n, step) => n + safeDuration(step.duration), 0) }
export function isGradientExpression(expression: Expression) { return expression.beats.some(beat => beat.gradientAction !== undefined) }
export function addLoadingAnimation(project: Project): Animation {
let expression = project.expressions.find(e => e.id === 'loading' && isGradientExpression(e)) ?? project.expressions.find(isGradientExpression)
const existing = expression && project.animations.find(a => a.steps.length === 1 && a.steps[0]!.expressionId === expression!.id)
if (existing) return existing
const needsWorking = (!expression || expression.poseExpressionId === 'working') && !project.expressions.some(e => e.id === 'working')
if (project.animations.length >= 100 || project.expressions.length + (expression ? 0 : 1) + (needsWorking ? 1 : 0) > 100) throw new Error('Make room in the library before adding Loading. Projects support up to 100 expressions and animations.')
if (!expression) {
expression = defaultExpressions().find(e => e.id === 'loading')!
if (project.expressions.some(e => e.id === expression!.id)) expression.id = uid('loading')
project.expressions.push(expression)
}
if (expression.poseExpressionId === 'working' && !project.expressions.some(e => e.id === 'working')) {
project.expressions.push(defaultExpressions().find(e => e.id === 'working')!)
}
const animation: Animation = { id: project.animations.some(a => a.id === 'loading') ? uid('loading') : 'loading', name: 'Loading', loop: true, steps: [{ id: uid('step'), expressionId: expression.id, duration: expressionDuration(expression) }] }
project.animations.push(animation)
return animation
}
export function definitionOf(project: Project, character: Character, animationIds?: string[]): Definition {
const animations = project.animations.filter(a => !animationIds || animationIds.includes(a.id))
const used = new Set(animations.flatMap(a => a.steps.map(s => s.expressionId)))
// A selected Loading export also carries the expression playing alongside it.
for (const id of used) {
const source = project.expressions.find(e => e.id === id)?.poseExpressionId
if (source) used.add(source)
}
return clone({ version: 1, character, expressions: animationIds ? project.expressions.filter(e => used.has(e.id)) : project.expressions, animations })
}
const numericKeys = Object.keys(BASE_POSE).filter(k => typeof BASE_POSE[k as keyof Pose] === 'number') as (keyof Pose)[]
const faceKeys = ['eye', 'mouth', 'faceSet', 'brows', 'cheeks', 'tongue', 'teeth', 'drool'] as const
export function faceLayers(pose: Pose): FaceLayer[] {
return [{ traits: Object.fromEntries(faceKeys.map(key => [key, pose[key]])) as FaceTraits, weight: 1 }]
}
export function mixFaceLayers(a: FaceLayer[], b: FaceLayer[], t: number): FaceLayer[] {
const combined = new Map<string, FaceLayer>()
for (const [layers, amount] of [[a, 1 - t], [b, t]] as const) for (const layer of layers) {
const weight = layer.weight * amount
if (weight <= 0) continue
const key = JSON.stringify(layer.traits), prior = combined.get(key)
if (prior) prior.weight += weight
else combined.set(key, { traits: layer.traits, weight })
}
return [...combined.values()]
}
export function mixPose(a: Pose, b: Pose, t: number): Pose {
const result = { ...(t < .5 ? a : b) }
for (const key of numericKeys) {
let delta = (b[key] as number) - (a[key] as number)
if (key.startsWith('rotation')) delta = ((delta + 180) % 360 + 360) % 360 - 180
;(result as unknown as Record<string, unknown>)[key] = (a[key] as number) + delta * t
}
return result
}
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 function sampleExpression(expression: Expression, time: number, expressions: Expression[] = [], visited = new Set<string>()): { pose: Pose; beatIndex: number; propAmount: number; tearAmount: number; gradientRotation?: number; gradientMix?: number; faceLayers: FaceLayer[] } {
const duration = expressionDuration(expression)
let t = mod(time, duration)
const gradient = isGradientExpression(expression)
let gradientStart = 0
for (let i = 0; i < expression.beats.length; i++) {
const beat = expression.beats[i]!
const beatDuration = safeDuration(beat.duration)
if (t < beatDuration || i === expression.beats.length - 1) {
const previous = expression.beats[(i - 1 + expression.beats.length) % expression.beats.length]!
const transition = Math.min(.45, beatDuration * .4)
const progress = Math.min(t / transition, 1)
const eased = progress * progress * (3 - 2 * progress)
const turn = Math.min(1, t / beatDuration)
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 } : {}) }
}
if (beat.gradientAction === 'rotate') gradientStart += 360
t -= beatDuration
}
return { pose: p(), faceLayers: faceLayers(p()), beatIndex: 0, propAmount: 1, tearAmount: 1 }
}
export function sampleDefinition(def: Definition, animationId: string, time: number, expressionId?: string): Sample {
const speedTime = Math.max(0, time) * def.character.speed
const animation = def.animations.find(a => a.id === animationId) ?? def.animations[0]
let expression = def.expressions.find(e => e.id === expressionId) ?? def.expressions[0]!
let local = speedTime, stepIndex = 0
let previousExpression: Expression | undefined
let blend = 1
if (!expressionId && animation?.steps.length) {
const total = animationDuration(animation)
local = animation.loop ? mod(speedTime, total) : Math.min(speedTime, total - .00001)
for (let i = 0; i < animation.steps.length; i++) {
const step = animation.steps[i]!
const stepDuration = safeDuration(step.duration)
if (local < stepDuration || i === animation.steps.length - 1) {
expression = def.expressions.find(e => e.id === step.expressionId) ?? expression
stepIndex = i
if (animation.steps.length > 1 && (i > 0 || animation.loop)) {
const prev = animation.steps[(i - 1 + animation.steps.length) % animation.steps.length]!
previousExpression = def.expressions.find(e => e.id === prev.expressionId)
blend = Math.min(1, local / Math.min(.35, stepDuration * .2))
}
local = local / stepDuration * expressionDuration(expression)
break
}
local -= stepDuration
}
}
const sample = sampleExpression(expression, local, def.expressions)
if (previousExpression && blend < 1) {
const previous = sampleExpression(previousExpression, expressionDuration(previousExpression) - .00001, def.expressions)
const eased = smooth(blend)
const oldProp = previous.pose.prop, oldTears = previous.pose.tears
sample.propAmount *= featureAmount(oldProp, sample.pose.prop, eased)
sample.tearAmount *= featureAmount(oldTears, sample.pose.tears, eased)
sample.pose = mixPose(previous.pose, sample.pose, eased)
sample.faceLayers = mixFaceLayers(previous.faceLayers, sample.faceLayers, eased)
if (previous.gradientRotation !== undefined || sample.gradientRotation !== undefined) {
const from = previous.gradientRotation ?? 0, to = sample.gradientRotation ?? 0
// Align the previous completed turn to the next expression's starting angle.
// Keep the incoming turn unwrapped so crossing 180 degrees cannot flip it.
const alignedFrom = from - Math.round(from / 360) * 360
sample.gradientRotation = alignedFrom + (to - alignedFrom) * eased
const fromMix = previous.gradientMix ?? 0
sample.gradientMix = fromMix + ((sample.gradientMix ?? 0) - fromMix) * eased
}
}
// All secondary motion uses the cycle period, so exported loops close exactly.
const period = expressionId ? expressionDuration(expression) : animation ? animationDuration(animation) : expressionDuration(expression)
const phase = 2 * Math.PI * mod(speedTime, period) / period
const blinkCount = Math.max(1, Math.round(period / def.character.blinkInterval))
const blinkPhase = mod(speedTime + period * .17, period / blinkCount)
const gradientOnly = isGradientExpression(expression) && !expression.poseExpressionId
const blink = !gradientOnly && def.character.blink && blinkPhase < .19 ? Math.sin(blinkPhase / .19 * Math.PI) ** 2 : 0
return { ...sample, expressionId: expression.id, stepIndex, blink, effectPhase: mod(speedTime, period) / period * Math.max(1, Math.round(period / 2.4)), bob: gradientOnly ? 0 : Math.sin(phase) * def.character.motion, breathe: gradientOnly ? 0 : Math.cos(phase) * def.character.motion }
}
function obj(v: unknown): Record<string, unknown> { if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error('This file is not a ClipLab project.'); return v as Record<string, unknown> }
function num(v: unknown, fallback: number, low: number, high: number) { return typeof v === 'number' && Number.isFinite(v) ? Math.min(high, Math.max(low, v)) : fallback }
function str(v: unknown, fallback: string, max = 80) { return typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : fallback }
function color(v: unknown, fallback: string) { return typeof v === 'string' && /^#[0-9a-f]{6}$/i.test(v) ? v : fallback }
function choice<T extends string>(v: unknown, options: T[], fallback: T) { return options.includes(v as T) ? v as T : fallback }
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<string, [number, number]> = { 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<string, unknown>)[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) }
}
export function parseProject(value: unknown): Project {
const v = obj(value)
if (v.version !== 1 || !Array.isArray(v.expressions) || !Array.isArray(v.animations)) throw new Error('Choose a ClipLab version 1 project or character definition.')
const charactersRaw = Array.isArray(v.characters) ? v.characters : v.character ? [v.character] : []
if (!charactersRaw.length || charactersRaw.length > 30 || !v.expressions.length || v.expressions.length > 100 || !v.animations.length || v.animations.length > 100) throw new Error('The project has an unsupported number of characters or sequences.')
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 132 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 } : {}) } }) }
})
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) {
const seen = new Set([expression.id])
let sourceId = expression.poseExpressionId
while (sourceId) {
const source = expressions.find(e => e.id === sourceId)
if (!source) throw new Error('An expression refers to missing face motion.')
if (seen.has(sourceId)) throw new Error('Face motion expressions cannot refer back to themselves.')
seen.add(sourceId); sourceId = source.poseExpressionId
}
}
const animations = v.animations.map((item, index): Animation => {
const a = obj(item)
if (!Array.isArray(a.steps) || !a.steps.length || a.steps.length > 64) throw new Error('Each animation needs 164 steps.')
return { id: str(a.id, `animation-${index}`), name: str(a.name, 'Animation'), loop: boolean(a.loop, true), steps: a.steps.map((item, i) => { const s = obj(item); if (!expressions.some(e => e.id === s.expressionId)) throw new Error('An animation refers to a missing expression.'); return { id: str(s.id, `step-${i}`), expressionId: s.expressionId as string, duration: num(s.duration, 3, .2, 30) } }) }
})
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) } : {}) }
}
/** Upgrade only untouched studio defaults, once; portable definitions stay literal. */
export function upgradeStudioDefaults(project: Project): Project {
if ((project.defaultsRevision ?? 0) >= 2) return project
const defaults = defaultExpressions()
for (const expression of project.expressions) {
const preset = defaults.find(e => e.id === expression.id)
if (!preset) continue
for (const beat of expression.beats) if (beat.pose.faceScale === 1 && preset.beats.some(b => b.id === beat.id)) beat.pose.faceScale = .75
if (expression.id === 'loading' && !expression.poseExpressionId && expression.beats.length === 2 && expression.beats.every(beat => {
const original = preset.beats.find(b => b.id === beat.id)
return original && original.gradientAction === beat.gradientAction && Object.keys(BASE_POSE).every(key => beat.pose[key as keyof Pose] === BASE_POSE[key as keyof Pose])
}) && project.expressions.some(e => e.id === 'working')) expression.poseExpressionId = 'working'
}
project.defaultsRevision = 2
return project
}

View File

@ -0,0 +1,75 @@
// Generated from ui/src/index.css by scripts/sync-agent-palette-tokens.mjs.
export const CAP_V1_COLORS = {
"bubblegum-sky": {
"a": "#8bd4ff",
"b": "#ff51bf"
},
"pink-lemonade": {
"a": "#ff26a8",
"b": "#fff78a"
},
"orchid-peach": {
"a": "#e771ff",
"b": "#ffd87c"
},
"coral-mint": {
"a": "#beffe8",
"b": "#ff797b"
},
"lime-lagoon": {
"a": "#b4ffa4",
"b": "#26dfff"
},
"arctic-blue": {
"a": "#97fff3",
"b": "#0084ff"
},
"solar-flare": {
"a": "#ecca5c",
"b": "#fe3c3f"
},
"violet-ember": {
"a": "#ff5c43",
"b": "#6262ff"
},
"deep-tide": {
"a": "#003d60",
"b": "#32fffc"
},
"coral-current": {
"a": "#26e2ff",
"b": "#ff6666"
},
"golden-hour": {
"a": "#ffcd4f",
"b": "#fde5ba"
},
"tangerine-cobalt": {
"a": "#ffad32",
"b": "#4169ff"
},
"electric-grove": {
"a": "#b4ff32",
"b": "#008d58"
},
"flamingo-jade": {
"a": "#ff4f9a",
"b": "#36edaa"
},
"cherry-pop": {
"a": "#ff69db",
"b": "#d51c46"
},
"turquoise-cherry": {
"a": "#37f0db",
"b": "#ff4f64"
},
"ultraviolet-tide": {
"a": "#7516cf",
"b": "#00e8d1"
},
"muted-dream": {
"a": "#a6aaad",
"b": "#44464a"
}
} as const;

View File

@ -0,0 +1,463 @@
// Vendored from ClipLab a050f724; 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 { 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'
export interface RenderOptions {
width: number; height: number; displaySize?: number; background?: string | null
cursor?: { x: number; y: number }; rotation?: { x: number; y: number; z: number }; zoom?: number; framing?: 'portrait' | 'character'; pixelRatio?: number
pointerLook?: PointerLook; eyeGazes?: EyeGazes; reducedMotion?: boolean
}
/** Cursor pitch owns the vertical look direction, independent of the resting tilt. */
export function characterRotation(character: Pick<Character, 'trueFront' | 'followRotation'>, pose: Pick<Pose, 'rotationX' | 'rotationY' | 'rotationZ'>, rotation = { x: -5, y: -12, z: -7 }, 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
}
}
/** 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<Character, 'followRotation'>, cursor: Gaze = { x: 0, y: 0 }, reducedMotion = false): Gaze {
const look = !reducedMotion && character.followRotation ? clampGaze(cursor) : { x: 0, y: 0 }
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
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,
simpleEyes
}
}
export const bodyHeight = (shape: Shape) => shape === 'capsule' ? 2 : 1
export function radiusAt(shape: Shape, y: number): number {
if (shape === 'sphere') return Math.sqrt(Math.max(0, .25 - y * y))
if (shape === 'capsule') { const dy = Math.max(Math.abs(y) - .5, 0); return Math.sqrt(Math.max(0, .25 - dy * dy)) }
if (y >= 0) return Math.sqrt(Math.max(0, .25 - y * y))
if (y >= -.43) return .5
return .43 + Math.sqrt(Math.max(0, .07 ** 2 - (y + .43) ** 2))
}
function geometryFor(shape: Shape, squareBottom = false): THREE.BufferGeometry {
if (shape === 'sphere') return new THREE.SphereGeometry(.5, 80, 64)
if (shape === 'capsule') return new THREE.CapsuleGeometry(.5, 1, 24, 80)
const points = [new THREE.Vector2(0, -.5), new THREE.Vector2(squareBottom ? .5 : .43, -.5)]
for (let i = 1; !squareBottom && i <= 12; i++) { const a = -Math.PI / 2 + i / 12 * Math.PI / 2; points.push(new THREE.Vector2(.43 + .07 * Math.cos(a), -.43 + .07 * Math.sin(a))) }
points.push(new THREE.Vector2(.5, 0))
for (let i = 1; i <= 32; i++) { const a = i / 32 * Math.PI / 2; points.push(new THREE.Vector2(.5 * Math.cos(a), .5 * Math.sin(a))) }
return new THREE.LatheGeometry(points, 80)
}
const vertexShader = `
uniform vec3 fillScale;
uniform vec3 fillOffset;
varying vec3 vPosition;
void main() {
vPosition = position * fillScale + fillOffset;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`
const fragmentShader = `
uniform vec3 colorA;
uniform vec3 colorB;
uniform float gradientOn;
uniform float toonOn;
uniform float insetFill;
uniform float angle;
uniform float bodyHeight;
varying vec3 vPosition;
void main() {
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);
gl_FragColor = vec4(color * mix(1.0, shade, toonOn), 1.0);
#include <colorspace_fragment>
}
`
function heart(ctx: CanvasRenderingContext2D, x: number, y: number, r: number) {
ctx.beginPath(); ctx.moveTo(x, y + r)
ctx.bezierCurveTo(x - r * 2, y - r * .1, x - r, y - r * 1.5, x, y - r * .55)
ctx.bezierCurveTo(x + r, y - r * 1.5, x + r * 2, y - r * .1, x, y + r); ctx.closePath(); ctx.fill()
}
function star(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, points = 5) {
ctx.beginPath()
for (let i = 0; i < points * 2; i++) { const a = i / (points * 2) * Math.PI * 2 - Math.PI / 2; const d = i % 2 ? r * .46 : r; const px = x + Math.cos(a) * d, py = y + Math.sin(a) * d; i ? ctx.lineTo(px, py) : ctx.moveTo(px, py) }
ctx.closePath(); ctx.fill()
}
const faceAspect = .76 / .57
const smoothstep = (a: number, b: number, x: number) => { const t = Math.max(0, Math.min(1, (x - a) / (b - a))); return t * t * (3 - 2 * t) }
const fract = (v: number) => ((v % 1) + 1) % 1
export function effectEnvelope(phase: number) { const t = fract(phase); return smoothstep(0, .18, t) * (1 - smoothstep(.72, 1, t)) }
export function eyeRadius(pose: Pose, character: Character, detail: Detail, side: number) { return (detail === 'eyes' ? 29 : 27) * pose.eyeSize * (side < 0 ? pose.leftScale : pose.rightScale) * (character.iris ? 1.2 : 1) }
export function projectedEye(character: Character, pose: Pose, blink: number, side: number, matrix: THREE.Matrix4, camera: THREE.Camera, layers?: FaceLayer[]) {
const radius = eyeRadius(pose, character, 'full', side)
const cx = 256 + side * 103 * pose.spacing + (side < 0 ? pose.leftX : pose.rightX)
const cy = 197 - (side < 0 ? pose.leftY : pose.rightY)
const angle = (pose.eyeTilt * side + (side < 0 ? pose.leftRotation : pose.rightRotation)) * Math.PI / 180
const h = Math.max(.12, 1 - blink) * pose.eyeHeight * (1 - .35 * (layers ? traitWeight(layers, t => t.eye === 'soft') : pose.eye === 'soft' ? 1 : 0))
const shell = character.elevated ? 1 + character.elevation : 1.003
const project = (dx: number, dy: number) => {
const tx = cx + Math.cos(angle) * dx - Math.sin(angle) * dy * h
const ty = cy + faceAspect * (Math.sin(angle) * dx + Math.cos(angle) * dy * h)
const x = (tx / 512 - .5) * .76 * pose.faceScale
const y = (.5 - ty / 512) * .57 * pose.faceScale + (character.shape === 'capsule' ? -.14 : -.025) + pose.faceY
const r = shell * radiusAt(character.shape, y / shell)
return new THREE.Vector3(x, y, Math.sqrt(Math.max(.001, r * r - x * x))).applyMatrix4(matrix).project(camera)
}
const center = project(0, 0)
// Central differences follow the tangent of the curved face at this eye.
const right = project(radius * .1, 0).sub(project(-radius * .1, 0)).multiplyScalar(5).add(center)
const up = project(0, -radius * .1).sub(project(0, radius * .1)).multiplyScalar(5).add(center)
return { center, right, up }
}
/** Resting highlights are local to the face, so they remain up-left when tilted. */
function irisRestGaze(character: Pick<Character, 'iris' | 'followCursor'>, gaze: Gaze, reducedMotion = false): Gaze {
if (!character.iris) return gaze
if (reducedMotion) return { x: -.22, y: .22 }
return character.followCursor ? gaze : { x: gaze.x - .22, y: gaze.y + .22 }
}
export function resolveEyeGazes(character: Character, pose: Pose, blink: number, matrix: THREE.Matrix4, camera: THREE.Camera, gaze: Gaze, pointer?: PointerLook, layers?: FaceLayer[]): EyeGazes {
const eye = (side: number) => {
const base = localGaze({ x: pose.gazeX + gaze.x, y: pose.gazeY + gaze.y }, pose.eyeTilt * side + (side < 0 ? pose.leftRotation : pose.rightRotation))
if (!pointer?.weight) return base
const { center, right, up } = projectedEye(character, pose, blink, side, matrix, camera, layers)
const look = gazeAtPoint(pointer, center, right, up), weight = Math.max(0, Math.min(1, pointer.weight))
return { x: base.x + (look.x - base.x) * weight, y: base.y + (look.y - base.y) * weight }
}
return { left: eye(-1), right: eye(1) }
}
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 } = {}) {
ctx.clearRect(0, 0, 512, 512)
if (detail === 'body') return
const ink = character.eyeColor, eyeY = detail === 'eyes' ? 254 : 197, spacing = 103 * pose.spacing
const internalGaze = character.iris && detail === 'full'
const gx = internalGaze ? 0 : (pose.gazeX + gaze.x) * 20, gy = internalGaze ? 0 : -(pose.gazeY + gaze.y) * 15
const layers = effects.faceLayers
const pupilAmount = !effects.simpleEyes && detail === 'full' ? layers ? traitWeight(layers, t => t.eye === 'pupil') : pose.eye === 'pupil' ? 1 : 0 : 0
ctx.lineCap = 'round'; ctx.lineJoin = 'round'
for (const side of [-1, 1]) {
const r = eyeRadius(pose, character, detail, side)
const eyeGaze = side < 0 ? effects.eyeGazes?.left : effects.eyeGazes?.right
const pupilEyes = !effects.simpleEyes && pose.eye === 'pupil' && detail === 'full'
const x = 256 + side * spacing + gx * (1 - pupilAmount) + (side < 0 ? pose.leftX : pose.rightX), y = eyeY + gy * (1 - pupilAmount) - (side < 0 ? pose.leftY : pose.rightY)
if (detail === 'full' && pose.blush > 0) {
ctx.save(); ctx.globalAlpha = pose.blush * .8; ctx.fillStyle = '#ff647a'; ctx.beginPath(); ctx.ellipse(x + side * 20, y + 56, 31, 24 * faceAspect, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore()
}
const localRotation = side < 0 ? pose.leftRotation : pose.rightRotation
// Equal world units in X/Y: the face mesh is wider than it is tall.
ctx.save(); ctx.translate(x, y); ctx.scale(1, faceAspect); ctx.rotate((pose.eyeTilt * side + localRotation) * Math.PI / 180)
ctx.fillStyle = ink; ctx.strokeStyle = ink; ctx.lineWidth = Math.max(13, r * .43)
const happyMouth = ['open', 'grin', 'smile', 'u-smile'].includes(pose.mouth)
const closed = ['closed', 'arc-up', 'arc-down'].includes(pose.eye) || (pose.eye === 'wink' && side > 0) || (detail === 'eyes' && ['open', 'grin'].includes(pose.mouth) && pose.eye === 'dot')
if (effects.simpleEyes) {
// Keep literal round dots even at eyes-only sizes or between styled beats.
ctx.fillStyle = '#000000'; ctx.scale(1, Math.max(.12, 1 - blink))
ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill()
} else if (layers) {
const look = eyeGaze ?? localGaze({ x: pose.gazeX + gaze.x, y: pose.gazeY + gaze.y }, pose.eyeTilt * side + localRotation)
drawMorphEye(ctx, pose, layers, r, side, blink, detail, ink, character.iris, look)
} else if (pose.faceSet === 'set-2' && pose.eye === 'wink' && side > 0) {
ctx.beginPath(); ctx.moveTo(r * .7, -r * .65); ctx.lineTo(-r * .55, 0); ctx.lineTo(r * .7, r * .65); ctx.stroke()
} else if (closed) {
const up = pose.eye === 'arc-up' || (pose.eye !== 'arc-down' && happyMouth)
ctx.beginPath(); ctx.arc(0, 0, r, up ? Math.PI : 0, up ? Math.PI * 2 : Math.PI); ctx.stroke()
} else if (pose.eye === 'squint') {
ctx.beginPath(); ctx.moveTo(-r * side * .7, -r * .8); ctx.lineTo(r * side * .6, 0); ctx.lineTo(-r * side * .7, r * .8); ctx.stroke()
} else if (blink > .7) {
ctx.beginPath(); ctx.moveTo(-r, 0); ctx.lineTo(r, 0); ctx.stroke()
} else {
const h = Math.max(.12, 1 - blink) * pose.eyeHeight * (pose.eye === 'soft' ? .65 : 1)
ctx.scale(1, h)
if (pose.cheeks) {
// The cheek is transparent, revealing the live shaded body beneath the eye.
ctx.beginPath(); ctx.rect(-r * 2, -r * 2, r * 4, r * 4); ctx.moveTo(r * .82, r * 1.13); ctx.arc(0, r * 1.13, r * .82, 0, Math.PI * 2); ctx.clip('evenodd')
}
if (pose.eye === 'star') star(ctx, 0, 0, r * 1.2)
else if (pose.eye === 'heart') { if (pose.faceSet === 'set-2' && detail === 'full') ctx.fillStyle = '#ff3f58'; heart(ctx, 0, 0, r) }
else if (pose.eye === 'half-lidded') { ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI); ctx.closePath(); ctx.fill() }
else if (pupilEyes) {
const outer = r * 1.5
ctx.fillStyle = '#fffef9'; ctx.beginPath(); ctx.arc(0, 0, outer, 0, Math.PI * 2); ctx.fill(); ctx.clip()
const look = eyeGaze ?? { x: pose.gazeX + gaze.x, y: pose.gazeY + gaze.y }
ctx.fillStyle = ink; ctx.beginPath(); ctx.arc(look.x * outer * .52, -look.y * outer * .52, outer * .4, 0, Math.PI * 2); ctx.fill()
}
else { ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill() }
if (internalGaze && !pupilEyes && pose.eye !== 'half-lidded') {
// The eye path also clips star/heart shapes and intersects cheek cutouts.
ctx.clip()
const dot = irisOffset(r, eyeGaze ?? { x: pose.gazeX + gaze.x, y: pose.gazeY + gaze.y }, eyeGaze ? 0 : pose.eyeTilt * side + localRotation, pose.cheeks)
ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(dot.x, dot.y, r * .28, 0, Math.PI * 2); ctx.fill()
}
}
ctx.restore()
if (detail === 'full' && layers) {
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()
}
if (pose.tears && detail === 'full') {
const phase = fract((effects.phase ?? .35) + (side < 0 ? .46 : 0)), alpha = effectEnvelope(phase) * (effects.tearAmount ?? 1)
ctx.save(); ctx.translate(x + side * (r + 18), y + 23 + phase * phase * 123); ctx.scale(1, faceAspect); ctx.rotate(-side * .2); ctx.globalAlpha = alpha; ctx.fillStyle = '#f6fdff'; drop(ctx, 0, 0, 12 + phase * 3); ctx.restore()
}
}
if (detail === 'eyes') return
if (layers) {
ctx.save(); ctx.translate(256 + gx * .25, 293 + gy * .25); 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 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()
if (pose.mouth === 'smile') { ctx.arc(0, -10, w, Math.PI * .18, Math.PI * .82); ctx.stroke() }
else if (pose.mouth === 'u-smile') { ctx.arc(0, -8, w * .56, 0, Math.PI); ctx.stroke() }
else if (pose.mouth === 'frown') { ctx.arc(0, 39, w, Math.PI * 1.2, Math.PI * 1.8); ctx.stroke() }
else if (pose.mouth === 'line' || pose.mouth === 'sleep') { ctx.moveTo(-w * .58, 0); ctx.lineTo(w * .58, 0); ctx.stroke() }
else if (pose.mouth === 'kiss') { ctx.moveTo(-w * .25, -21); ctx.bezierCurveTo(w * .48, -34, w * .53, -2, 0, 0); ctx.bezierCurveTo(w * .53, 2, w * .48, 34, -w * .25, 21); ctx.stroke() }
else if (pose.mouth === 'tongue-out') {
ctx.beginPath(); ctx.moveTo(-w, -9); ctx.quadraticCurveTo(0, -2, w, -9); ctx.stroke()
ctx.fillStyle = '#ff526c'; ctx.beginPath(); ctx.moveTo(-w * .6, 5); ctx.lineTo(w * .6, 5); ctx.bezierCurveTo(w * .88, 85 * pose.mouthOpen + 28, -w * .88, 85 * pose.mouthOpen + 28, -w * .6, 5); ctx.fill()
}
else if (pose.mouth === 'wave') { ctx.moveTo(-w, 4); ctx.bezierCurveTo(-w * .35, -25, w * .35, 25, w, -4); ctx.stroke() }
else {
const h = (broad ? 48 : 22) + 72 * pose.mouthOpen
if (pose.mouth === 'oh') ctx.arc(0, 14, w * (.55 + .3 * pose.mouthOpen), 0, Math.PI * 2)
else if (pose.mouth === 'cry') { ctx.moveTo(-w, 30); ctx.bezierCurveTo(-w * 1.1, -h, w * 1.1, -h, w, 30); ctx.quadraticCurveTo(w, 44, w * .76, 38); ctx.quadraticCurveTo(0, 24, -w * .76, 38); ctx.quadraticCurveTo(-w, 44, -w, 30) }
else { const tilt = pose.mouth === 'grin' ? 24 : 0; ctx.moveTo(-w, -11); ctx.quadraticCurveTo(0, 5, w, -11 - tilt); ctx.bezierCurveTo(w * 1.02, h, -w * 1.02, h, -w, -11) }
ctx.closePath(); ctx.fill(); ctx.save(); ctx.clip()
if (pose.tongue) { ctx.fillStyle = '#f37b83'; ctx.beginPath(); ctx.ellipse(10, h * .65, w * .65, h * .34, -.1, 0, Math.PI * 2); ctx.fill() }
if (pose.teeth) { ctx.fillStyle = '#fffef8'; ctx.beginPath(); ctx.roundRect(-w * .76, -23, w * 1.52, 30, 12); ctx.fill() }
ctx.restore()
// Rebuild the outer path after the clipped interior details changed the canvas path.
ctx.beginPath()
if (pose.mouth === 'oh') ctx.arc(0, 14, w * (.55 + .3 * pose.mouthOpen), 0, Math.PI * 2)
else if (pose.mouth === 'cry') { ctx.moveTo(-w, 30); ctx.bezierCurveTo(-w * 1.1, -h, w * 1.1, -h, w, 30); ctx.quadraticCurveTo(w, 44, w * .76, 38); ctx.quadraticCurveTo(0, 24, -w * .76, 38); ctx.quadraticCurveTo(-w, 44, -w, 30) }
else { const tilt = pose.mouth === 'grin' ? 24 : 0; ctx.moveTo(-w, -11); ctx.quadraticCurveTo(0, 5, w, -11 - tilt); ctx.bezierCurveTo(w * 1.02, h, -w * 1.02, h, -w, -11) }
ctx.closePath(); ctx.strokeStyle = ink; ctx.stroke()
}
if (pose.drool) {
const anchor = droolAnchor(mouthGeometry(pose).outline, w)
if (pose.mouth === 'cry') anchor.y -= 27
drawDrool(ctx, anchor)
}
ctx.restore()
}
export function drawProp(ctx: CanvasRenderingContext2D, prop: Pose['prop'], color: string, phase?: number) {
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'
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)
else if (prop === 'question') { ctx.font = 'bold 144px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('?', 0, 48) }
else if (prop === 'sweat') drop(ctx, 0, 0, 38)
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()
}
}
export class CharacterRenderer {
readonly canvas: HTMLCanvasElement | null
readonly gl: THREE.WebGLRenderer | null
private scene = new THREE.Scene()
private camera = new THREE.OrthographicCamera(-1, 1, 1, -1, .1, 30)
private root = new THREE.Group()
private body: THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial>
private lightFill: THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial>
private face: THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial>
private faceCanvas: HTMLCanvasElement | null = null
private faceCtx: CanvasRenderingContext2D | null = null
private faceTexture: THREE.CanvasTexture | null = null
private propCanvas: HTMLCanvasElement | null = null
private propCtx: CanvasRenderingContext2D | null = null
private propTexture: THREE.CanvasTexture | null = null
private prop: THREE.Sprite
private shadow: THREE.Mesh<THREE.CircleGeometry, THREE.MeshBasicMaterial>
private shape: Shape = 'capsule'
private squareBottom = false
private lastFaceKey = ''
private resolvedEyes: EyeGazes | undefined
private lastProp = ''
private disposed = false
private options: RenderOptions
private captured?: { character: Character; sample: Sample; gaze: Gaze; simpleEyes: boolean }
constructor(canvas: HTMLCanvasElement | null, options: RenderOptions) {
this.canvas = canvas; this.options = options
this.gl = null
if (canvas) {
this.faceCanvas = document.createElement('canvas')
this.propCanvas = document.createElement('canvas')
this.gl = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, preserveDrawingBuffer: true, powerPreference: 'low-power' })
this.gl.outputColorSpace = THREE.SRGBColorSpace
this.gl.setPixelRatio(options.pixelRatio ?? Math.min(Math.max(window.devicePixelRatio || 1, 1) * 2, 4))
this.faceCanvas.width = this.faceCanvas.height = 1024
this.faceCtx = this.faceCanvas.getContext('2d')!
this.faceCtx.scale(2, 2)
this.faceTexture = new THREE.CanvasTexture(this.faceCanvas); this.faceTexture.colorSpace = THREE.SRGBColorSpace
this.faceTexture.generateMipmaps = true; this.faceTexture.minFilter = THREE.LinearMipmapLinearFilter
this.propCanvas.width = this.propCanvas.height = 512; this.propCtx = this.propCanvas.getContext('2d')!; this.propCtx.scale(2, 2)
this.propTexture = new THREE.CanvasTexture(this.propCanvas); this.propTexture.colorSpace = THREE.SRGBColorSpace
}
const material = new THREE.ShaderMaterial({
uniforms: { colorA: { value: new THREE.Color() }, colorB: { value: new THREE.Color() }, gradientOn: { value: 1 }, toonOn: { value: 1 }, insetFill: { value: 0 }, fillScale: { value: new THREE.Vector3(1, 1, 1) }, fillOffset: { value: new THREE.Vector3() }, angle: { value: 0 }, bodyHeight: { value: 2 } }, vertexShader, fragmentShader
})
this.body = new THREE.Mesh(geometryFor('capsule'), material)
const fillMaterial = material.clone(); fillMaterial.depthTest = false; fillMaterial.depthWrite = false; fillMaterial.uniforms.insetFill!.value = 1
this.lightFill = new THREE.Mesh(new THREE.CapsuleGeometry(.42, .94, 24, 80), fillMaterial); this.lightFill.renderOrder = 1
this.face = new THREE.Mesh(new THREE.PlaneGeometry(1, 1, 56, 40), new THREE.MeshBasicMaterial({ map: this.faceTexture, transparent: true, alphaTest: .008, depthWrite: false, side: THREE.FrontSide, toneMapped: false }))
this.face.geometry.setAttribute('faceValid', new THREE.BufferAttribute(new Float32Array(this.face.geometry.attributes.position!.count).fill(1), 1))
this.face.material.onBeforeCompile = shader => {
shader.vertexShader = 'attribute float faceValid; varying float vFaceValid;\n' + shader.vertexShader.replace('#include <begin_vertex>', '#include <begin_vertex>\nvFaceValid = faceValid;')
shader.fragmentShader = 'varying float vFaceValid;\n' + shader.fragmentShader.replace('void main() {', 'void main() {\nif (vFaceValid < 0.99) discard;')
}
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.camera.position.set(0, 0, 8); this.camera.lookAt(0, 0, 0)
this.resize(options.width, options.height, options.displaySize)
}
resize(width: number, height: number, displaySize?: number) {
this.options = { ...this.options, width: Math.max(1, width), height: Math.max(1, height), displaySize: displaySize ?? Math.min(width, height) }
this.gl?.setSize(this.options.width, this.options.height, false)
}
render(character: Character, sample: Sample, options: Partial<RenderOptions> = {}, gaze = { x: 0, y: 0 }) {
if (this.disposed) return
this.options = { ...this.options, ...options }
const displaySize = this.options.displaySize ?? Math.min(this.options.width, this.options.height)
const appearance = faceForSize(character, sample, displaySize)
character = appearance.character; sample = appearance.sample
const squareBottom = character.shape === 'cap' && displaySize <= 24
if (character.shape !== this.shape || squareBottom !== this.squareBottom) { this.shape = character.shape; this.squareBottom = squareBottom; this.body.geometry.dispose(); this.body.geometry = geometryFor(this.shape, squareBottom); this.lightFill.geometry.dispose(); this.lightFill.geometry = this.shape === 'capsule' ? new THREE.CapsuleGeometry(.42, .94, 24, 80) : geometryFor(this.shape) }
const { pose } = sample
const height = bodyHeight(this.shape)
const detail = detailAt(displaySize)
const u = this.body.material.uniforms
;(u.colorA!.value as THREE.Color).set(character.color); (u.colorB!.value as THREE.Color).set(character.color2)
u.gradientOn!.value = character.gradient ? 1 : (sample.gradientMix ?? (sample.gradientRotation !== undefined ? 1 : 0)); u.toonOn!.value = character.toon ? 1 : 0
u.angle!.value = (character.gradientAngle + (sample.gradientRotation ?? 0)) * Math.PI / 180; u.bodyHeight!.value = height
const rotation = characterRotation(this.options.reducedMotion ? { ...character, followRotation: false } : character, pose, this.options.rotation, this.options.cursor)
this.root.rotation.set(rotation.x * Math.PI / 180, rotation.y * Math.PI / 180, rotation.z * Math.PI / 180, 'YXZ')
this.root.position.y = character.lockPosition ? 0 : sample.bob * .025 * height
const stretch = pose.squash + (character.lockPosition ? 0 : sample.breathe * .008)
this.root.scale.set(1 / Math.sqrt(stretch), stretch, 1 / Math.sqrt(stretch))
this.lightFill.visible = character.toon
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
// 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 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()))
this.lightFill.position.copy(lightOffset)
const fu = this.lightFill.material.uniforms
for (const name of ['colorA', 'colorB']) (fu[name]!.value as THREE.Color).copy(u[name]!.value as THREE.Color)
for (const name of ['gradientOn', 'toonOn', 'angle', 'bodyHeight']) fu[name]!.value = u[name]!.value
;(fu.fillScale!.value as THREE.Vector3).setScalar(fillScale); (fu.fillOffset!.value as THREE.Vector3).copy(lightOffset)
const zoom = displaySize <= 128 ? 1 : this.options.zoom ?? 1
const aspect = this.options.width / this.options.height
// 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) {
// 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!
const radius = (sphere.radius + sphere.center.length()) * Math.max(1, this.root.scale.x, this.root.scale.y, this.root.scale.z)
const safeHalf = (radius + this.root.position.length()) / .9
half = Math.max(half, safeHalf, safeHalf / aspect)
}
this.camera.left = -half * aspect; this.camera.right = half * aspect; this.camera.top = half; this.camera.bottom = -half; this.camera.updateProjectionMatrix()
this.camera.updateMatrixWorld(true)
this.face.visible = detail !== 'body'
// Pointer directions stay relative to the screen when the character rolls or turns.
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)
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 vertices = this.face.geometry.attributes.position!
const uv = this.face.geometry.attributes.uv!
const valid = this.face.geometry.attributes.faceValid!
const shell = character.elevated ? 1 + character.elevation : 1.003
const scale = pose.faceScale
const offset = (this.shape === 'capsule' ? -.14 : -.025) + pose.faceY
for (let i = 0; i < vertices.count; i++) {
const x = (uv.getX(i) - .5) * .76 * scale
const y = (uv.getY(i) - .5) * .57 * scale + offset
const r = shell * radiusAt(this.shape, y / shell)
const z = Math.sqrt(Math.max(.001, r * r - x * x))
vertices.setXYZ(i, x, y, z)
valid.setX(i, x * x < r * r && Math.abs(y) < height / 2 * shell ? 1 : 0)
}
vertices.needsUpdate = true; valid.needsUpdate = true; this.face.geometry.computeBoundingSphere()
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 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.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)
this.captured = { character, sample, gaze: faceGaze, simpleEyes: appearance.simpleEyes }
}
snapshotScene() {
if (!this.captured) throw new Error('Render the character before taking a snapshot.')
return { ...this.captured, options: this.options, camera: this.camera, body: this.body, lightFill: this.lightFill, face: this.face, prop: this.prop, shadow: this.shadow, eyeGazes: this.resolvedEyes }
}
orientation() { return this.root.quaternion.clone() }
eyeGazes() { return this.resolvedEyes ? { left: { ...this.resolvedEyes.left }, right: { ...this.resolvedEyes.right } } : undefined }
dispose() {
if (this.disposed) return
this.disposed = true
this.body.geometry.dispose(); this.body.material.dispose(); this.lightFill.geometry.dispose(); this.lightFill.material.dispose(); this.face.geometry.dispose(); this.face.material.dispose()
this.faceTexture?.dispose(); this.propTexture?.dispose(); this.prop.material.dispose(); this.shadow.geometry.dispose(); this.shadow.material.dispose()
this.gl?.dispose(); this.gl?.forceContextLoss()
}
}
let thumbRenderer: CharacterRenderer | undefined
const thumbs = new Map<string, string>()
export function thumbnail(character: Character, pose: Pose = BASE_POSE, size = 96): string {
const key = JSON.stringify([character, pose, size])
const cached = thumbs.get(key); if (cached) return cached
try {
if (!thumbRenderer) thumbRenderer = new CharacterRenderer(document.createElement('canvas'), { width: 192, height: 192, pixelRatio: 1, displaySize: size })
thumbRenderer.render({ ...character, shadow: false }, { pose, blink: 0, bob: 0, breathe: 0, expressionId: '', beatIndex: 0, stepIndex: 0 }, { displaySize: size, rotation: { x: -3, y: -8, z: -5 }, zoom: 1.08 })
const url = thumbRenderer.canvas!.toDataURL('image/png'); if (thumbs.size > 250) thumbs.clear(); thumbs.set(key, url); return url
} catch { return '' }
}

View File

@ -0,0 +1,82 @@
// ClipLab runtime adapted for Paperclip; geometry/animation model remain upstream.
// See PROVENANCE.md and LICENSE.
import { CharacterRenderer } from './renderer.js'
import { sampleDefinition, animationDuration, type Definition } from './model.js'
import { centeredGaze, easeGaze, pointerGaze } from './gaze.js'
export interface CharacterOptions {
animation?: string; followCursor?: boolean; followRotation?: boolean;
trackingRegion?: HTMLElement; trackingScope?: 'region' | 'page'; displaySize?: number; onComplete?: () => void; onError?: () => void;
}
/** One renderer; no frame callbacks or pointer listeners while hidden. */
export function createCharacter(target: HTMLElement, definition: Definition, options: CharacterOptions = {}) {
const canvas = document.createElement('canvas')
canvas.style.cssText = 'display:block;width:100%;height:100%;touch-action:pan-y;'
canvas.setAttribute('aria-hidden', 'true')
const renderer = new CharacterRenderer(canvas, { width: target.clientWidth || 256, height: target.clientHeight || 256, displaySize: options.displaySize, framing: 'character' })
target.appendChild(canvas)
let animation = options.animation ?? 'idle', elapsed = 0, last = 0, raf = 0
let visible = false, destroyed = false, playing = true, tracking = false
let gaze = centeredGaze(), goal = centeredGaze()
const region = options.trackingScope === 'page' ? target.ownerDocument.documentElement : options.trackingRegion ?? target
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)')
const coarse = window.matchMedia('(pointer: coarse)')
const canRun = () => !destroyed && visible && !document.hidden && !reduced.matches
const render = () => renderer.render({ ...definition.character, followCursor: options.followCursor !== false, followRotation: options.followRotation !== false }, sampleDefinition(definition, animation, elapsed), { cursor: gaze, reducedMotion: reduced.matches }, gaze)
const pointer = (event: PointerEvent) => { if (event.pointerType !== 'touch') goal = pointerGaze(event, target.getBoundingClientRect()) }
const leave = () => { goal = centeredGaze() }
function track(enabled: boolean) {
if (tracking === enabled) return
tracking = enabled
if (enabled) { region.addEventListener('pointermove', pointer, { passive: true }); region.addEventListener('pointerleave', leave) }
else { region.removeEventListener('pointermove', pointer); region.removeEventListener('pointerleave', leave); goal = centeredGaze() }
}
function sync() {
cancelAnimationFrame(raf); raf = 0
track(canRun() && !coarse.matches && (options.followCursor !== false || options.followRotation !== false))
if (canRun() && playing) { last = performance.now(); raf = requestAnimationFrame(frame) }
}
function frame(now: number) {
if (!canRun()) { sync(); return }
const seconds = Math.min((now - last) / 1000, .1); last = now
elapsed += seconds; gaze = easeGaze(gaze, goal, seconds, false)
try { render() } catch { playing = false; sync(); options.onError?.(); return }
const selected = definition.animations.find(a => a.id === animation)
if (selected && !selected.loop && elapsed * definition.character.speed >= animationDuration(selected)) {
animation = 'idle'; elapsed = 0; options.onComplete?.()
}
raf = requestAnimationFrame(frame)
}
function renderSafely() {
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() })
const contextLost = (event: Event) => { event.preventDefault(); playing = false; sync(); options.onError?.() }
function destroy() {
if (destroyed) return
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.observe(target); intersection.observe(target)
document.addEventListener('visibilitychange', sync); reduced.addEventListener('change', sync); coarse.addEventListener('change', sync)
canvas.addEventListener('webglcontextlost', contextLost)
render()
} catch (error) { destroy(); throw error }
return {
setAnimation(id: string) { if (animation === id) return; animation = id; elapsed = 0; renderSafely() },
setDefinition(value: Definition) { definition = value; renderSafely() },
seek(seconds: number) { elapsed = Math.max(0, seconds); renderSafely() },
pause() { playing = false; sync() },
play() { playing = true; sync() },
destroy,
}
}

View File

@ -0,0 +1,15 @@
import type { AgentAppearance, AgentAvatarSize, CharacterState } from "../agent-appearance.js";
import { characterDefinition, characterStill } from "./definition.js";
import { CharacterRenderer } from "./renderer.js";
import { snapshotSvg } from "./svg-snapshot.js";
/** Same geometry/face projection as the live character, without DOM or WebGL. */
export function renderAgentSvg(appearance: AgentAppearance, size: AgentAvatarSize, scale: 1 | 2, state: CharacterState = "rest", muted = false): string {
const definition = characterDefinition(appearance, muted);
const renderer = new CharacterRenderer(null, { width: size * scale, height: size * scale, displaySize: size });
try {
renderer.render({ ...definition.character, trueFront: true, lockPosition: true, followCursor: false, followRotation: false },
characterStill(definition, state), { rotation: { x: 0, y: 0, z: 0 } });
return snapshotSvg(renderer.snapshotScene(), "agent-");
} finally { renderer.dispose(); }
}

View File

@ -0,0 +1,109 @@
// Vendored from ClipLab a050f724; 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.
type Matrix = [number, number, number, number, number, number]
type Command = { op: string; points: number[] }
const identity = (): Matrix => [1, 0, 0, 1, 0, 0]
export const xml = (value: unknown) => String(value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[c]!)
export const number = (n: number) => String(Math.round(n * 100000) / 100000)
const point = (m: Matrix, x: number, y: number) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]
const inverse = (m: Matrix): Matrix => {
const d = m[0] * m[3] - m[1] * m[2]
return d ? [m[3] / d, -m[1] / d, -m[2] / d, m[0] / d, (m[2] * m[5] - m[3] * m[4]) / d, (m[1] * m[4] - m[0] * m[5]) / d] : identity()
}
export class SvgCanvas {
fillStyle = '#000000'; strokeStyle = '#000000'; lineWidth = 1; globalAlpha = 1
lineCap = 'butt'; lineJoin = 'miter'; font = '10px sans-serif'; textAlign = 'start'
private matrix = identity()
private clips: string[] = []
private stack: ReturnType<SvgCanvas['state']>[] = []
private path: Command[] = []
private nodes: string[] = []
private definitions: string[] = []
bounds = { left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity }
constructor(private prefix: string, private project?: ProjectPoint) {}
private state() { return { matrix: [...this.matrix] as Matrix, clips: [...this.clips], fillStyle: this.fillStyle, strokeStyle: this.strokeStyle, lineWidth: this.lineWidth, globalAlpha: this.globalAlpha, lineCap: this.lineCap, lineJoin: this.lineJoin, font: this.font, textAlign: this.textAlign } }
save() { this.stack.push(this.state()) }
restore() { const state = this.stack.pop(); if (state) Object.assign(this, state) }
clearRect() { this.nodes = []; this.definitions = []; this.path = []; this.bounds = { left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity } }
transform(a: number, b: number, c: number, d: number, e: number, f: number) {
const m = this.matrix
this.matrix = [m[0] * a + m[2] * b, m[1] * a + m[3] * b, m[0] * c + m[2] * d, m[1] * c + m[3] * d, m[0] * e + m[2] * f + m[4], m[1] * e + m[3] * f + m[5]]
}
translate(x: number, y: number) { this.transform(1, 0, 0, 1, x, y) }
scale(x: number, y: number) { this.transform(x, 0, 0, y, 0, 0) }
rotate(a: number) { this.transform(Math.cos(a), Math.sin(a), -Math.sin(a), Math.cos(a), 0, 0) }
beginPath() { this.path = [] }
closePath() { this.path.push({ op: 'Z', points: [] }) }
private command(op: string, coords: number[]) {
const points = []
for (let i = 0; i < coords.length; i += 2) points.push(...point(this.matrix, coords[i]!, coords[i + 1]!))
this.path.push({ op, points })
}
moveTo(x: number, y: number) { this.command('M', [x, y]) }
lineTo(x: number, y: number) { this.command(this.path.length ? 'L' : 'M', [x, y]) }
bezierCurveTo(...coords: [number, number, number, number, number, number]) { this.command('C', coords) }
quadraticCurveTo(...coords: [number, number, number, number]) { this.command('Q', coords) }
rect(x: number, y: number, w: number, h: number) { this.moveTo(x, y); this.lineTo(x + w, y); this.lineTo(x + w, y + h); this.lineTo(x, y + h); this.closePath() }
roundRect(x: number, y: number, w: number, h: number, radius: number) {
const r = Math.min(radius, Math.abs(w) / 2, Math.abs(h) / 2)
this.moveTo(x + r, y); this.lineTo(x + w - r, y); this.arc(x + w - r, y + r, r, -Math.PI / 2, 0)
this.lineTo(x + w, y + h - r); this.arc(x + w - r, y + h - r, r, 0, Math.PI / 2)
this.lineTo(x + r, y + h); this.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI)
this.lineTo(x, y + r); this.arc(x + r, y + r, r, Math.PI, Math.PI * 1.5); this.closePath()
}
arc(x: number, y: number, radius: number, start: number, end: number, ccw = false) { this.ellipse(x, y, radius, radius, 0, start, end, ccw) }
ellipse(x: number, y: number, rx: number, ry: number, rotation: number, start: number, end: number, ccw = false) {
const tau = Math.PI * 2
let delta = end - start
if (!ccw) delta = delta >= tau ? tau : ((delta % tau) + tau) % tau
else delta = delta <= -tau ? -tau : -(((-delta % tau) + tau) % tau)
const at = (a: number) => [x + rx * Math.cos(a) * Math.cos(rotation) - ry * Math.sin(a) * Math.sin(rotation), y + rx * Math.cos(a) * Math.sin(rotation) + ry * Math.sin(a) * Math.cos(rotation)]
const tangent = (a: number) => [-rx * Math.sin(a) * Math.cos(rotation) - ry * Math.cos(a) * Math.sin(rotation), -rx * Math.sin(a) * Math.sin(rotation) + ry * Math.cos(a) * Math.cos(rotation)]
const first = at(start); this.lineTo(first[0]!, first[1]!)
const segments = Math.max(1, Math.ceil(Math.abs(delta) / (Math.PI / 2)))
for (let i = 0; i < segments; i++) {
const a = start + delta * i / segments, b = start + delta * (i + 1) / segments, k = 4 / 3 * Math.tan((b - a) / 4)
const p = at(a), q = at(b), u = tangent(a), v = tangent(b)
this.bezierCurveTo(p[0]! + k * u[0]!, p[1]! + k * u[1]!, q[0]! - k * v[0]!, q[1]! - k * v[1]!, q[0]!, q[1]!)
}
}
private data(matrix = identity()) {
return this.path.map(c => {
const coords = []
for (let i = 0; i < c.points.length; i += 2) coords.push(...point(matrix, c.points[i]!, c.points[i + 1]!))
return c.op + coords.map(number).join(' ')
}).join(' ')
}
private add(node: string) { this.nodes.push(this.clips.reduceRight((value, id) => `<g clip-path="url(#${id})">${value}</g>`, node)) }
private paint(stroke: boolean, rule = 'nonzero') {
if (this.globalAlpha <= 0) return
const expansion = stroke ? this.lineWidth * Math.max(Math.hypot(this.matrix[0], this.matrix[1]), Math.hypot(this.matrix[2], this.matrix[3])) / 2 : 0
for (const c of this.path) for (let i = 0; i < c.points.length; i += 2) {
this.bounds.left = Math.min(this.bounds.left, c.points[i]! - expansion); this.bounds.right = Math.max(this.bounds.right, c.points[i]! + expansion)
this.bounds.top = Math.min(this.bounds.top, c.points[i + 1]! - expansion); this.bounds.bottom = Math.max(this.bounds.bottom, c.points[i + 1]! + expansion)
}
if (this.project) {
const inv = inverse(this.matrix)
const commands = this.path.map(c => ({ op: c.op, points: c.points.flatMap((_, i) => i % 2 ? [] : point(inv, c.points[i]!, c.points[i + 1]!)) }))
const contours = flatten(commands)
const outlines = stroke ? contours.flatMap(c => strokeOutline(c, this.lineWidth)) : contours.map(c => ({ ...c, closed: true }))
const project: ProjectPoint = p => { const [x, y] = point(this.matrix, p.x, p.y); return this.project!({ x: x!, y: y! }) }
this.add(`<path d="${pathData(outlines, project)}" opacity="${number(this.globalAlpha)}" fill="${xml(stroke ? this.strokeStyle : this.fillStyle)}" fill-rule="${rule}"/>`)
return
}
this.add(`<path d="${this.data(inverse(this.matrix))}" transform="matrix(${this.matrix.map(number).join(' ')})" opacity="${number(this.globalAlpha)}" ${stroke ? `fill="none" stroke="${xml(this.strokeStyle)}" stroke-width="${number(this.lineWidth)}" stroke-linecap="${xml(this.lineCap)}" stroke-linejoin="${xml(this.lineJoin)}"` : `fill="${xml(this.fillStyle)}" fill-rule="${rule}"`}/>`)
}
fill(rule = 'nonzero') { this.paint(false, rule) }
stroke() { this.paint(true) }
clip(rule = 'nonzero') {
const id = `${this.prefix}-clip-${this.definitions.length}`
this.definitions.push(`<clipPath id="${id}" clipPathUnits="userSpaceOnUse"><path d="${this.project ? pathData(flatten(this.path).map(c => ({ ...c, closed: true })), this.project) : this.data()}" clip-rule="${rule}"/></clipPath>`); this.clips.push(id)
}
fillText(text: string, x: number, y: number) {
const size = Number(this.font.match(/([\d.]+)px/)?.[1] ?? 10)
this.add(`<text x="${number(x)}" y="${number(y)}" transform="matrix(${this.matrix.map(number).join(' ')})" font-family="sans-serif" font-size="${size}" font-weight="${this.font.includes('bold') ? 'bold' : 'normal'}" text-anchor="${this.textAlign === 'center' ? 'middle' : 'start'}" fill="${xml(this.fillStyle)}" opacity="${number(this.globalAlpha)}">${xml(text)}</text>`)
}
markup() { return `<defs>${this.definitions.join('')}</defs>${this.nodes.join('')}` }
}

View File

@ -0,0 +1,105 @@
// Vendored from ClipLab a050f724; 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 }
export type PathCommand = { op: string; points: number[] }
export type Contour = { points: Point[]; closed: boolean }
export type ProjectPoint = (point: Point) => Point
const distance = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y)
const middle = (a: Point, b: Point) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 })
function lineDistance(p: Point, a: Point, b: Point) {
const dx = b.x - a.x, dy = b.y - a.y, t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / (dx * dx + dy * dy || 1)))
return distance(p, { x: a.x + dx * t, y: a.y + dy * t })
}
export function simplify(points: Point[], tolerance = .06): Point[] {
if (points.length <= 2) return points
let max = tolerance, index = -1
for (let i = 1; i < points.length - 1; i++) { const d = lineDistance(points[i]!, points[0]!, points.at(-1)!); if (d > max) { max = d; index = i } }
return index < 0 ? [points[0]!, points.at(-1)!] : [...simplify(points.slice(0, index + 1), tolerance).slice(0, -1), ...simplify(points.slice(index), tolerance)]
}
export function flatten(commands: PathCommand[], tolerance = .08): Contour[] {
const contours: Contour[] = []
let current: Contour | undefined
const append = (p: Point) => { if (!current) { current = { points: [], closed: false }; contours.push(current) } current.points.push(p) }
function cubic(a: Point, b: Point, c: Point, d: Point, depth = 0) {
if (depth >= 12 || Math.max(lineDistance(b, a, d), lineDistance(c, a, d)) <= tolerance) { append(d); return }
const ab = middle(a, b), bc = middle(b, c), cd = middle(c, d), abc = middle(ab, bc), bcd = middle(bc, cd), mid = middle(abc, bcd)
cubic(a, ab, abc, mid, depth + 1); cubic(mid, bcd, cd, d, depth + 1)
}
for (const command of commands) {
const p = command.points, end = { x: p.at(-2)!, y: p.at(-1)! }, start = current?.points.at(-1) ?? end
if (command.op === 'M') { current = undefined; append(end) }
else if (command.op === 'Z') { if (current) current.closed = true }
else if (command.op === 'C') cubic(start, { x: p[0]!, y: p[1]! }, { x: p[2]!, y: p[3]! }, end)
else if (command.op === 'Q') cubic(start, { x: start.x + (p[0]! - start.x) * 2 / 3, y: start.y + (p[1]! - start.y) * 2 / 3 }, { x: end.x + (p[0]! - end.x) * 2 / 3, y: end.y + (p[1]! - end.y) * 2 / 3 }, end)
else append(end)
}
return contours
}
function arc(center: Point, start: number, delta: number, radius: number) {
const count = Math.max(1, Math.ceil(Math.abs(delta) / .18))
return Array.from({ length: count + 1 }, (_, i) => ({ x: center.x + Math.cos(start + delta * i / count) * radius, y: center.y + Math.sin(start + delta * i / count) * radius }))
}
/** Round joins/caps, matching all line artwork in the character renderer. */
export function strokeOutline(contour: Contour, width: number): Contour[] {
let points = contour.points.filter((p, i, all) => !i || distance(p, all[i - 1]!) > 1e-7)
if (points.length > 1 && distance(points[0]!, points.at(-1)!) < 1e-7) points = points.slice(0, -1)
if (points.length < 2) return []
const r = width / 2, len = points.length
const direction = (a: Point, b: Point) => { const l = distance(a, b); return { x: (b.x - a.x) / l, y: (b.y - a.y) / l } }
const side = (sign: number) => points.flatMap((p, i) => {
const before = direction(points[(i - 1 + len) % len]!, p), after = direction(p, points[(i + 1) % len]!)
if (!contour.closed && (!i || i === len - 1)) { const d = i ? before : after; return [{ x: p.x - d.y * r * sign, y: p.y + d.x * r * sign }] }
const turn = Math.atan2(before.x * after.y - before.y * after.x, before.x * after.x + before.y * after.y)
if (turn * sign < -.001) return arc(p, Math.atan2(before.x * sign, -before.y * sign), turn, r)
const denom = Math.max(.05, 1 + before.x * after.x + before.y * after.y)
return [{ x: p.x - (before.y + after.y) * r * sign / denom, y: p.y + (before.x + after.x) * r * sign / denom }]
})
const left = side(1), right = side(-1)
if (contour.closed) return [{ points: left, closed: true }, { points: right.reverse(), closed: true }]
const first = direction(points[0]!, points[1]!), last = direction(points[len - 2]!, points[len - 1]!)
return [{ closed: true, points: [...left, ...arc(points.at(-1)!, Math.atan2(last.x, -last.y), -Math.PI, r), ...right.reverse(), ...arc(points[0]!, Math.atan2(-first.x, first.y), -Math.PI, r)] }]
}
export function pathData(contours: Contour[], project: ProjectPoint = p => p, tolerance = .06) {
const n = (value: number) => String(Math.round(value * 1000) / 1000)
return contours.map(contour => {
// Subdivide straight art-space edges too: the face shell is curved.
const points: Point[] = []
const all = contour.closed ? [...contour.points, contour.points[0]!] : contour.points
function segment(a: Point, b: Point, depth = 0) {
const pa = project(a), pb = project(b), mid = middle(a, b), pm = project(mid)
if (depth < 10 && (lineDistance(pm, pa, pb) > tolerance || distance(a, b) > 12)) { segment(a, mid, depth + 1); segment(mid, b, depth + 1) }
else points.push(pb)
}
if (!all.length) return ''
points.push(project(all[0]!)); for (let i = 1; i < all.length; i++) segment(all[i - 1]!, all[i]!)
return simplify(points, tolerance).map((p, i) => `${i ? 'L' : 'M'}${n(p.x)} ${n(p.y)}`).join('') + (contour.closed ? 'Z' : '')
}).join('')
}
/** Union a triangle/polygon tessellation by cancelling shared edges, keeping only
* the boundary loops. The output has no interior mesh edges or per-triangle layers. */
export function boundaryContours(polygons: Point[][]): Contour[] {
const key = (p: Point) => `${Math.round(p.x * 1e5)},${Math.round(p.y * 1e5)}`
const edges = new Map<string, { a: Point; b: Point; start: string; end: string }>()
for (const poly of polygons) for (let i = 0; i < poly.length; i++) {
const a = poly[i]!, b = poly[(i + 1) % poly.length]!, start = key(a), end = key(b)
if (start === end) continue
const reverse = `${end}/${start}`, forward = `${start}/${end}`
if (edges.has(reverse)) edges.delete(reverse); else edges.set(forward, { a, b, start, end })
}
const starts = new Map<string, Set<string>>()
for (const [id, e] of edges) { const set = starts.get(e.start) ?? new Set<string>(); set.add(id); starts.set(e.start, set) }
const contours: Contour[] = []
while (edges.size) {
const first = edges.values().next().value!, points = [first.a]
let next: string | undefined = `${first.start}/${first.end}`
while (next) {
const e = edges.get(next); if (!e) break
edges.delete(next); starts.get(e.start)!.delete(next); points.push(e.b)
if (e.end === first.start) break
next = starts.get(e.end)?.values().next().value
}
if (points.length > 2) contours.push({ points, closed: true })
}
return contours
}

View File

@ -0,0 +1,145 @@
// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE.
import * as THREE from 'three'
import { 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'
type Snapshot = ReturnType<CharacterRenderer['snapshotScene']>
type Vertex = { x: number; y: number; z: number; t: number }
type Triangle = { points: Vertex[]; indices: number[] }
const cross = (a: Pick<Vertex, 'x' | 'y'>, b: Pick<Vertex, 'x' | 'y'>, c: Pick<Vertex, 'x' | 'y'>) => (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)
function hull(points: Vertex[]) {
const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y)
const half = (items: Vertex[]) => { const out: Vertex[] = []; for (const p of items) { while (out.length > 1 && cross(out.at(-2)!, out.at(-1)!, p) <= 0) out.pop(); out.push(p) } return out }
return [...half(sorted).slice(0, -1), ...half([...sorted].reverse()).slice(0, -1)]
}
function clip(points: Vertex[], threshold: number, above: boolean) {
const out: Vertex[] = []
for (let i = 0; i < points.length; i++) {
const a = points[i]!, b = points[(i + 1) % points.length]!, insideA = above ? a.z >= threshold : a.z <= threshold, insideB = above ? b.z >= threshold : b.z <= threshold
if (insideA) out.push(a)
if (insideA !== insideB) {
const f = (threshold - a.z) / (b.z - a.z)
out.push({ x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f, z: a.z + (b.z - a.z) * f, t: a.t + (b.t - a.t) * f })
}
}
return out
}
/** Best-fit planar gradient, weighted by visible projected area. A curved 3D
* surface is not exactly representable by one SVG linear gradient; this keeps
* the authored colors and a compact, editable fill at every camera angle. */
export function fitGradient(triangles: { points: Vertex[] }[]) {
let weight = 0, x = 0, y = 0, t = 0
const samples: { p: Vertex; w: number }[] = []
for (const { points } of triangles) {
const w = Math.abs(cross(points[0]!, points[1]!, points[2]!)) / 6
for (const p of points) { samples.push({ p, w }); weight += w; x += p.x * w; y += p.y * w; t += p.t * w }
}
if (!weight) return { x: 0, y: 0, t: .5, gx: 0, gy: 0 }
x /= weight; y /= weight; t /= weight
let xx = 0, xy = 0, yy = 0, xt = 0, yt = 0
for (const { p, w } of samples) { const dx = p.x - x, dy = p.y - y, dt = p.t - t; xx += dx * dx * w; xy += dx * dy * w; yy += dy * dy * w; xt += dx * dt * w; yt += dy * dt * w }
const det = xx * yy - xy * xy
return { x, y, t, gx: det ? (xt * yy - yt * xy) / det : 0, gy: det ? (yt * xx - xt * xy) / det : 0 }
}
/** Warp each facial outline through the same UV mesh as the live renderer.
* This emits the artwork once instead of repeating it in every mesh triangle. */
export function faceProjector(face: Snapshot['face'], project: (point: THREE.Vector3) => Point): ProjectPoint {
const { widthSegments: nx, heightSegments: ny } = face.geometry.parameters
const position = face.geometry.getAttribute('position')
return p => {
const u = p.x / 512 * nx, v = p.y / 512 * ny
const ix = Math.max(0, Math.min(nx - 1, Math.floor(u))), iy = Math.max(0, Math.min(ny - 1, Math.floor(v))), fx = u - ix, fy = v - iy
const a = ix + iy * (nx + 1), b = a + nx + 1, c = b + 1, d = a + 1
const indices = fx + fy <= 1 ? [a, d, b] : [c, b, d], weights = fx + fy <= 1 ? [1 - fx - fy, fx, fy] : [fx + fy - 1, 1 - fx, 1 - fy]
const value = new THREE.Vector3()
indices.forEach((index, i) => value.addScaledVector(new THREE.Vector3().fromBufferAttribute(position, index), weights[i]!))
return project(value.applyMatrix4(face.matrixWorld))
}
}
export function snapshotSvg(snapshot: Snapshot, prefix = `cliplab-${crypto.randomUUID()}-`): string {
const { character, sample, camera, options, body, lightFill, face, prop, shadow } = snapshot
const { width, height } = options
const defs: string[] = [], contents: string[] = []
const project = (p: THREE.Vector3) => { const v = p.clone().project(camera); return { x: (v.x + 1) * width / 2, y: (1 - v.y) * height / 2, z: v.z, t: 0 } }
const triangles = (mesh: THREE.Mesh, shading = false): { triangles: Triangle[]; vertices: Vertex[] } => {
const geometry = mesh.geometry, position = geometry.getAttribute('position'), index = geometry.index
const material = mesh.material as THREE.ShaderMaterial, uniforms = shading ? material.uniforms : undefined
const angle = uniforms?.angle?.value ?? 0, bodyHeight = uniforms?.bodyHeight?.value ?? 1
const scale = uniforms?.fillScale?.value as THREE.Vector3 | undefined, offset = uniforms?.fillOffset?.value as THREE.Vector3 | undefined
const vertices: Vertex[] = []
for (let i = 0; i < position.count; i++) {
const local = new THREE.Vector3().fromBufferAttribute(position, i), v = project(local.clone().applyMatrix4(mesh.matrixWorld))
if (shading) {
local.multiply(scale ?? new THREE.Vector3(1, 1, 1)).add(offset ?? new THREE.Vector3())
v.t = .5 + local.y / bodyHeight * Math.cos(angle) + local.x * Math.sin(angle)
}
vertices.push(v)
}
const triangles: Triangle[] = []
for (let i = 0; i < (index?.count ?? position.count); i += 3) {
const indices = [0, 1, 2].map(j => index ? index.getX(i + j) : i + j), points = indices.map(i => vertices[i]!)
if (cross(points[0]!, points[1]!, points[2]!) < -1e-8) triangles.push({ points, indices })
}
return { triangles, vertices }
}
const shadedColor = (color: THREE.Color, shade: number) => '#' + color.clone().multiplyScalar(shade).getHexString()
const outline = (vertices: Vertex[]) => pathData([{ points: hull(vertices), closed: true }])
const surface = (mesh: THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial>, 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 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)) {
if (length < 1e-18) fill = shadedColor(colorB.clone().lerp(colorA, Math.max(0, Math.min(1, t))), shade)
else {
const id = `${label}-gradient`
defs.push(`<linearGradient id="${id}" gradientUnits="userSpaceOnUse" color-interpolation="linearRGB" x1="${n(x - gx * t / length)}" y1="${n(y - gy * t / length)}" x2="${n(x + gx * (1 - t) / length)}" y2="${n(y + gy * (1 - t) / length)}"><stop stop-color="${shadedColor(colorB, shade)}"/><stop offset="1" stop-color="${shadedColor(colorA, shade)}"/></linearGradient>`)
fill = `url(#${id})`
}
}
contents.push(`<g id="${label}" data-name="${label === 'body' ? 'Outer body' : 'Inner body'}"><path fill="${fill}" d="${outline(data.vertices)}"/></g>`)
return data
}
if (options.background) contents.push(`<rect width="${width}" height="${height}" fill="${xml(options.background)}"/>`)
if (shadow.visible) {
const material = shadow.material, data = triangles(shadow)
contents.push(`<g id="ground-shadow" data-name="Shadow"><path d="${outline(data.vertices)}" fill="#${material.color.getHexString()}" opacity="${n(material.opacity)}"/></g>`)
}
const bodyData = surface(body, 'body')
if (lightFill.visible) surface(lightFill, 'candle-light')
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 })
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(`<clipPath id="face-visible"><path d="${pathData(boundaryContours(visible.map(t => t.points)))}"/></clipPath>`)
if (!face.geometry.boundingSphere) face.geometry.computeBoundingSphere()
const center = project(face.geometry.boundingSphere!.center.clone().applyMatrix4(face.matrixWorld))
transparent.push({ z: center.z, markup: `<g clip-path="url(#face-visible)">${art.markup()}</g>` })
}
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)
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
if (!foreground) {
const occlusion = pathData(boundaryContours(bodyData.triangles.map(t => clip(t.points, center.z, false)).filter(p => p.length >= 3)))
defs.push(`<mask id="prop-occlusion" maskUnits="userSpaceOnUse" x="0" y="0" width="${width}" height="${height}"><rect width="${width}" height="${height}" fill="white"/><path d="${occlusion}" fill="black"/></mask>`)
}
transparent.push({ z: foreground ? -Infinity : center.z, markup: `<g id="supporting-elements" data-name="Supporting elements"${foreground ? '' : ' mask="url(#prop-occlusion)"'} opacity="${n(prop.material.opacity)}"><g transform="translate(${n(center.x - w / 2)} ${n(center.y - h / 2)}) scale(${n(w / 256)} ${n(h / 256)})">${art.markup()}</g></g>` })
}
if (transparent.length) contents.push(`<g id="face" data-name="Facial expression">${transparent.sort((a, b) => b.z - a.z).map(layer => layer.markup).join('')}</g>`)
const metadata = { format: 'cliplab-svg-snapshot', gradientProjection: 'planar-fit', character, pose: sample.pose, gradientRotation: sample.gradientRotation ?? 0, effectiveGradientAngle: character.gradientAngle + (sample.gradientRotation ?? 0), rotation: options.rotation, cursor: options.cursor }
// Multiple downloaded characters can safely be placed inline in the same page.
const artwork = (`<defs>${defs.join('')}</defs>${contents.join('')}`)
.replace(/\bid="([^"]+)"/g, (_, id: string) => `id="${prefix}${id}"`)
.replace(/url\(#([^)]+)\)/g, (_, id: string) => `url(#${prefix}${id})`)
.replace(/href="#([^"]+)"/g, (_, id: string) => `href="#${prefix}${id}"`)
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img"><title>${xml(character.name)} — ClipLab snapshot</title><metadata>${xml(JSON.stringify(metadata))}</metadata>${artwork}</svg>`
}

View File

@ -2761,3 +2761,5 @@ export type { ExecutionContinuationEnvelope } from "./types/execution-continuati
export type { ExecutionProjection, ExecutionReconciliation, ExecutionBlocker } from "./types/execution-projection.js";
export { EXECUTION_RECONCILIATION_CAUSES, requiresExecutionReconciliation } from "./types/execution-projection.js";
export * from "./agent-appearance.js";

View File

@ -1,3 +1,4 @@
import type { AgentAppearance } from "../agent-appearance.js";
import type {
AgentAdapterType,
PauseReason,
@ -78,6 +79,8 @@ export interface Agent {
role: AgentRole;
title: string | null;
icon: string | null;
appearance?: AgentAppearance | null;
avatarUrl?: string;
status: AgentStatus;
reportsTo: string | null;
capabilities: string | null;

View File

@ -1,3 +1,4 @@
import type { AgentAppearance } from "../agent-appearance.js";
import type { AgentEnvConfig } from "./secrets.js";
import type { RoutineVariable } from "./routine.js";
import type { IssueCommentAuthorType, PermissionKey } from "../constants.js";
@ -217,6 +218,7 @@ export interface CompanyPortabilityAgentManifestEntry {
role: string;
title: string | null;
icon: string | null;
appearance?: AgentAppearance | null;
capabilities: string | null;
reportsToSlug: string | null;
reportsToExistingAgentId: string | null;

View File

@ -1,3 +1,4 @@
import type { AgentAppearance } from "../agent-appearance.js";
import type { BillingType, CostStatus } from "../constants.js";
export interface CostEvent {
@ -47,6 +48,8 @@ export interface IssueCostSummary {
export interface CostByAgent {
agentId: string;
agentName: string | null;
agentAppearance?: AgentAppearance | null;
avatarUrl?: string;
agentStatus: string | null;
costCents: number;
inputTokens: number;
@ -94,6 +97,8 @@ export interface CostByBiller {
export interface CostByAgentModel {
agentId: string;
agentName: string | null;
agentAppearance?: AgentAppearance | null;
avatarUrl?: string;
provider: string;
biller: string;
billingType: BillingType;

View File

@ -1,3 +1,4 @@
import type { AgentAppearance } from "../agent-appearance.js";
import type {
SecretAccessOutcome,
SecretBindingTargetType,
@ -364,6 +365,8 @@ export type SecretProposalStatus =
/** Minimal agent reference surfaced on a proposal (proposer / binding target). */
export interface SecretProposalAgentRef {
appearance?: AgentAppearance | null;
avatarUrl?: string;
id: string;
name: string;
/** lucide icon slug, if the agent has one. */

View File

@ -1,3 +1,4 @@
import type { AgentAppearance } from "../agent-appearance.js";
/**
* Work Timeline (Gantt) types shared between the aggregation service
* (`server/src/services/work-timeline.ts`) and the UI page
@ -15,6 +16,8 @@ export interface WorkTimelineActor {
type: TimelineActorType;
name: string;
avatar?: string | null;
appearance?: AgentAppearance | null;
avatarUrl?: string;
}
export interface WorkTimelineSpan {

View File

@ -1,3 +1,4 @@
import { agentAppearanceSchema } from "../agent-appearance.js";
import { z } from "zod";
import {
AGENT_ICON_NAMES,
@ -78,6 +79,7 @@ export const createAgentSchema = z.object({
role: z.enum(AGENT_ROLES).optional().default("general"),
title: z.string().optional().nullable(),
icon: z.enum(AGENT_ICON_NAMES).optional().nullable(),
appearance: agentAppearanceSchema.optional(),
reportsTo: z.string().guid().optional().nullable(),
capabilities: z.string().optional().nullable(),
desiredSkills: z.array(agentDesiredSkillSelectionSchema).optional(),

View File

@ -1,3 +1,4 @@
import { agentAppearanceSchema } from "../agent-appearance.js";
import { z } from "zod";
import { PERMISSION_KEYS } from "../constants.js";
import {
@ -77,6 +78,7 @@ export const portabilityAgentManifestEntrySchema = z.object({
role: z.string().min(1),
title: z.string().nullable(),
icon: z.string().nullable(),
appearance: agentAppearanceSchema.nullable().optional(),
capabilities: z.string().nullable(),
reportsToSlug: z.string().min(1).nullable(),
adapterType: z.string().min(1),

View File

@ -0,0 +1,13 @@
import fs from "node:fs";
import { fileURLToPath } from "node:url";
const css = fs.readFileSync(new URL("../ui/src/index.css", import.meta.url), "utf8");
const values = {};
for (const [, name, channel, color] of css.matchAll(/--agent-cap-v1-([a-z-]+)-(a|b):\s*(#[0-9a-f]{6});/g)) {
(values[name] ??= {})[channel] = color;
}
if (Object.keys(values).length !== 18 || Object.values(values).some(v => !v.a || !v.b)) throw new Error("Incomplete cap-v1 palette tokens");
const output = "// Generated from ui/src/index.css by scripts/sync-agent-palette-tokens.mjs.\nexport const CAP_V1_COLORS = " + JSON.stringify(values, null, 2) + " as const;\n";
const target = new URL("../packages/shared/src/cliplab/palette-tokens.ts", import.meta.url);
if (process.argv.includes("--check")) {
if (fs.readFileSync(target, "utf8") !== output) throw new Error("Run node scripts/sync-agent-palette-tokens.mjs");
} else fs.writeFileSync(target, output);

View File

@ -0,0 +1,102 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import { Readable } from "node:stream";
import { S3Client, PutObjectCommand, HeadObjectCommand, GetObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
import { createS3StorageProvider } from "../storage/s3-provider.js";
import path from "node:path";
import express from "express";
import type { Server } from "node:http";
import sharp from "sharp";
import { appearanceForPalette } from "@paperclipai/shared";
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
import { createAgentAvatarService, avatarCacheKey, type AgentAvatarRequest } from "../services/agent-avatars.js";
import { createAgentAvatarPool } from "../services/agent-avatar-pool.js";
import { agentAvatarRoutes } from "../routes/agent-avatars.js";
const request: AgentAvatarRequest = { appearance: appearanceForPalette("arctic-blue"), size: 24, scale: 2, pose: "rest", muted: false };
const cleanups: Array<() => Promise<unknown>> = [];
afterEach(async () => { await Promise.all(cleanups.splice(0).map(fn => fn())); });
async function storage() {
const dir = await mkdtemp(path.join(os.tmpdir(), "agent-avatar-test-"));
cleanups.push(() => rm(dir, { recursive: true, force: true }));
return createLocalDiskStorageProvider(dir);
}
async function serve(service: ReturnType<typeof createAgentAvatarService>) {
const app = express(); app.use("/api", agentAvatarRoutes(service));
const server = await new Promise<Server>(resolve => { const running = app.listen(0, "127.0.0.1", () => resolve(running)); });
cleanups.push(() => new Promise<void>((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`;
}
describe("on-demand agent avatars", () => {
it("coalesces cold requests, survives restarts and regenerates deleted cache entries", async () => {
const provider = await storage();
const render = vi.fn(async () => Buffer.from("png-bytes"));
const service = createAgentAvatarService(provider, render);
const results = await Promise.all(Array.from({ length: 10 }, () => service.get(request)));
expect(render).toHaveBeenCalledTimes(1);
expect(new Set(results.map(result => result.etag)).size).toBe(1);
await Promise.all(results.map(async result => { for await (const _ of result.stream) { /* consume */ } }));
(await createAgentAvatarService(provider, render).get(request)).stream.destroy();
expect(render).toHaveBeenCalledTimes(1);
await provider.deleteObject({ objectKey: avatarCacheKey(request) });
(await service.get(request)).stream.destroy();
expect(render).toHaveBeenCalledTimes(2);
});
it("uses the configured S3 prefix and reuses bytes across service instances", async () => {
const objects = new Map<string, Buffer>();
const send = vi.spyOn(S3Client.prototype, "send").mockImplementation(async (command: any) => {
const key = command.input.Key as string;
expect(command.input.Bucket).toBe("avatar-test");
expect(key).toMatch(/^paperclip\/generated-agent-avatars\/cap-v1\//);
if (command instanceof PutObjectCommand) { objects.set(key, Buffer.from(command.input.Body as Uint8Array)); return {}; }
if (command instanceof DeleteObjectCommand) { objects.delete(key); return {}; }
const bytes = objects.get(key);
if (!bytes) throw Object.assign(new Error("missing"), { name: "NoSuchKey" });
if (command instanceof HeadObjectCommand) return { ContentLength: bytes.length };
if (command instanceof GetObjectCommand) return { Body: Readable.from(bytes), ContentLength: bytes.length };
throw new Error("Unexpected S3 command");
});
try {
const provider = createS3StorageProvider({ bucket: "avatar-test", region: "us-east-1", prefix: "paperclip" });
const render = vi.fn(async () => Buffer.from("s3-avatar"));
const first = await createAgentAvatarService(provider, render).get(request);
const second = await createAgentAvatarService(provider, render).get(request);
expect(second.etag).toEqual(first.etag);
const firstBytes: Buffer[] = [], secondBytes: Buffer[] = [];
for await (const chunk of first.stream) firstBytes.push(Buffer.from(chunk));
for await (const chunk of second.stream) secondBytes.push(Buffer.from(chunk));
expect(Buffer.concat(secondBytes)).toEqual(Buffer.concat(firstBytes));
expect(render).toHaveBeenCalledTimes(1);
await provider.deleteObject({ objectKey: avatarCacheKey(request) });
(await createAgentAvatarService(provider, render).get(request)).stream.destroy();
expect(render).toHaveBeenCalledTimes(2);
} finally { send.mockRestore(); }
});
it("renders a PNG in an isolated worker without DOM or WebGL", async () => {
const pool = createAgentAvatarPool(1); cleanups.push(() => pool.close());
const png = await pool.render(request);
expect(await sharp(png).metadata()).toMatchObject({ width: 48, height: 48, format: "png", hasAlpha: true });
}, 20_000);
it("serves public images with content ETags and validates the finite request space", async () => {
const render = vi.fn(async () => Buffer.from("png-bytes"));
const url = await serve(createAgentAvatarService(await storage(), render));
const first = await fetch(url + "?size=24&scale=2");
expect(first.status).toBe(200);
expect(first.headers.get("content-type")).toContain("image/png");
expect(first.headers.get("cache-control")).toContain("immutable");
await first.arrayBuffer();
const second = await fetch(url + "?size=24&scale=2", { headers: { "If-None-Match": first.headers.get("etag")! } });
expect(second.status).toBe(304);
for (const suffix of ["?size=99999", "?scale=3", "?color=red", "?size=24&size=32"]) {
const invalid = await fetch(url + suffix); expect(invalid.status).toBe(400); await invalid.text();
}
expect(render).toHaveBeenCalledTimes(1);
});
it("does not cache rendering failures and permits retry", async () => {
const render = vi.fn().mockRejectedValueOnce(new Error("unavailable")).mockResolvedValue(Buffer.from("png"));
const url = await serve(createAgentAvatarService(await storage(), render));
const first = await fetch(url); expect(first.status).toBe(503); expect(first.headers.get("cache-control")).toBe("no-store"); await first.text();
const retry = await fetch(url); expect(retry.status).toBe(200); await retry.arrayBuffer();
});
});

View File

@ -136,6 +136,8 @@ describeEmbeddedPostgres("agent hire idempotency within a run", () => {
expect(second.status, JSON.stringify(second.body)).toBe(200);
expect(second.body.idempotent).toBe(true);
expect(second.body.agent?.id).toBe(createdId);
expect(first.body.agent?.appearance).toMatchObject({ schemaVersion: 1, characterVersion: "cap-v1" });
expect(second.body.agent?.appearance).toEqual(first.body.agent?.appearance);
// The retry must not have auto-renamed a duplicate to "Sam 2".
expect(second.body.agent?.name).toBe("Sam");

View File

@ -0,0 +1,53 @@
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq, sql } from "drizzle-orm";
import { agents, companies, createDb } from "@paperclipai/db";
import { agentAppearanceSchema, appearanceForPalette, legacyAgentAppearance } from "@paperclipai/shared";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { agentService } from "../services/agents.js";
describe("persisted agent personas", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let companyId: string;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("agent-persona-persistence-");
db = createDb(database.connectionString);
companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Persona test", issuePrefix: "CAP" });
}, 30_000);
afterAll(async () => { await database?.cleanup(); });
it("randomizes once, accepts a saved draft and preserves identity across edits and revision rollback", async () => {
const service = agentService(db);
const random = await service.create(companyId, { name: "Random", role: "engineer", adapterType: "process" });
expect(agentAppearanceSchema.safeParse(random.appearance).success).toBe(true);
const appearance = appearanceForPalette("arctic-blue");
const original = await service.create(companyId, { name: "Draft", role: "engineer", adapterType: "process", appearance });
expect(original.appearance).toEqual(appearance);
const updated = await service.update(original.id, { name: "Renamed" }, { recordRevision: { source: "test" } });
expect(updated?.appearance).toEqual(appearance);
const [revision] = await service.listConfigRevisions(original.id);
await service.update(original.id, { name: "Changed again", status: "paused" });
const restored = await service.rollbackConfigRevision(original.id, revision.id, {});
expect(restored?.appearance).toEqual(appearance);
expect((await agentService(db).getById(original.id))?.avatarUrl).toContain("/arctic-blue/rest.png?size=512");
const [row] = await db.select().from(agents).where(eq(agents.id, original.id));
expect(row.appearance).toEqual(appearance);
});
it("backfills legacy IDs with exactly the same persisted identity as the runtime fallback", async () => {
const ids = Array.from({ length: 20 }, () => randomUUID());
await db.insert(agents).values(ids.map((id, i) => ({ id, companyId, name: `Legacy ${i}`, role: "engineer", appearance: null, icon: "bot" })));
const migration = await readFile(new URL("../../../packages/db/src/migrations/0272_marvelous_madame_web.sql", import.meta.url), "utf8");
// Replaying the entire migration must preserve saved choices and tolerate
// an already-created column from an earlier worktree migration number.
for (let replay = 0; replay < 2; replay++) {
for (const statement of migration.split("--> statement-breakpoint")) await db.execute(sql.raw(statement));
}
for (const id of ids) {
const [row] = await db.select().from(agents).where(eq(agents.id, id));
expect(row.appearance).toEqual(legacyAgentAppearance(id));
expect(row.icon).toBe("bot");
}
});
});

View File

@ -99,6 +99,7 @@ describeEmbeddedPostgres("pending approval agent config integrity", () => {
budgetMonthlyCents: 1234,
metadata: { source: "hire-form" },
agentId: pending.id,
appearance: pending.appearance,
},
decisionNote: null,
decidedByUserId: null,
@ -143,6 +144,7 @@ describeEmbeddedPostgres("pending approval agent config integrity", () => {
await expect(agentSvc.getById(pending.id)).resolves.toMatchObject({
status: "idle",
appearance: pending.appearance,
name: "Pending Coder",
role: "engineer",
title: "Software Engineer",

View File

@ -1727,6 +1727,22 @@ describe("company portability", () => {
expect(exported.warnings.filter((warning) => warning.includes("could not be exported portably"))).toHaveLength(1);
});
it("round-trips the saved visual persona through the portable bundle", async () => {
const appearance = { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "arctic-blue" };
const source = await agentSvc.list();
agentSvc.list.mockResolvedValue(source.map((agent: Record<string, unknown>) => ({ ...agent, appearance })));
const portability = companyPortabilityService({} as any);
const include = { company: true, agents: true, projects: false, issues: false, skills: false };
const exported = await portability.exportBundle("company-1", { include });
expect(asTextFile(exported.files[".paperclip.yaml"])).toContain("arctic-blue");
agentSvc.list.mockResolvedValue([]);
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({ ...input, id: `imported-${input.name}` }));
await portability.importBundle({ source: { type: "inline", files: exported.files, rootPath: exported.rootPath }, include,
target: { mode: "new_company", newCompanyName: "Imported personas" }, collisionStrategy: "rename", agents: "all" }, "user-1");
expect(agentSvc.create).toHaveBeenCalled();
for (const [, input] of agentSvc.create.mock.calls) expect(input).toMatchObject({ appearance });
});
it("reads env inputs back from .paperclip.yaml during preview import", async () => {
const portability = companyPortabilityService({} as any);

View File

@ -19,6 +19,7 @@ const apiPrefixes: Record<string, string> = {
"activity.ts": "/api",
"adapters.ts": "/api",
"agents.ts": "/api",
"agent-avatars.ts": "/api",
"attention.ts": "/api",
"approvals.ts": "/api",
"assets.ts": "/api",

View File

@ -1,3 +1,4 @@
import { agentAvatarRoutes } from "./routes/agent-avatars.js";
import { toolActionDeliveryService } from "./services/tool-action-delivery.js";
import express, { Router, type Request as ExpressRequest } from "express";
import {
@ -613,6 +614,7 @@ export async function createApp(
// Mount API routes
const api = Router();
api.use(agentAvatarRoutes());
api.use(boardMutationGuard());
api.use(
"/health",

View File

@ -0,0 +1,52 @@
import { pipeline } from "node:stream/promises";
import { Router } from "express";
import { z } from "zod";
import { AGENT_PALETTE_IDS, AGENT_AVATAR_SIZES, CHARACTER_STATES, appearanceForPalette, type AgentAvatarSize } from "@paperclipai/shared";
import { createAgentAvatarService } from "../services/agent-avatars.js";
import { createStorageProviderFromConfig } from "../storage/provider-registry.js";
import { loadConfig } from "../config.js";
import { logger } from "../middleware/logger.js";
const requestSchema = z.object({
version: z.literal("cap-v1"),
palette: z.enum([...AGENT_PALETTE_IDS, "muted-dream"]),
pose: z.enum(CHARACTER_STATES),
size: z.string().regex(/^\d+$/).default("512").transform(Number).refine(n => (AGENT_AVATAR_SIZES as readonly number[]).includes(n)),
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<typeof createAgentAvatarService>) {
const router = Router();
let service = injected;
router.get("/agent-avatars/:version/:palette/:file", async (req, res) => {
const file = String(req.params.file);
const parsed = requestSchema.safeParse({ ...req.query, version: req.params.version, palette: req.params.palette, pose: file.endsWith(".png") ? file.slice(0, -4) : file });
if (!file.endsWith(".png") || !parsed.success || Object.keys(req.query).some(key => key !== "size" && key !== "scale")) {
res.setHeader("Cache-Control", "no-store");
res.status(400).json({ error: "Unsupported avatar version, palette, pose, size, or scale" }); return;
}
const { palette, pose, size, scale } = parsed.data;
try {
service ??= createAgentAvatarService(createStorageProviderFromConfig(loadConfig()));
const { stream, byteSize, etag } = await service.get({ appearance: appearanceForPalette(palette === "muted-dream" ? AGENT_PALETTE_IDS[0] : palette), muted: palette === "muted-dream", pose, size: size as AgentAvatarSize, scale: Number(scale) as 1 | 2 });
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
res.setHeader("ETag", etag);
res.setHeader("X-Content-Type-Options", "nosniff");
res.type("png");
const validators = req.get("if-none-match")?.split(",").map(value => value.trim().replace(/^W\//, ""));
if (validators?.some(value => value === "*" || value === etag)) { stream.destroy(); res.status(304).end(); return; }
res.setHeader("Content-Length", byteSize);
await pipeline(stream, res);
} catch (error) {
logger.warn({ err: error }, "Could not render agent avatar");
if (res.headersSent || res.destroyed) { res.destroy(); return; }
res.removeHeader("Content-Length");
res.removeHeader("ETag");
res.setHeader("Cache-Control", "no-store");
res.setHeader("Retry-After", "5");
res.status(503).json({ error: "Avatar temporarily unavailable" });
}
});
return router;
}

View File

@ -1,3 +1,4 @@
import { resolveAgentAppearance, agentAvatarUrl } from "@paperclipai/shared";
import { paperclipRunnerTransitionConfig, normalizeLegacyRunnerProvider, isPaperclipRunnerProvider } from "@paperclipai/adapter-utils";
import { executionProjectionForRun, executionProjectionsForRuns } from "../services/execution-projection.js";
import { Router, type NextFunction, type Request, type Response } from "express";
@ -3730,6 +3731,7 @@ export function agentRoutes(
id: agentsTable.id,
companyId: agentsTable.companyId,
agentName: agentsTable.name,
agentAppearance: agentsTable.appearance,
role: agentsTable.role,
title: agentsTable.title,
status: agentsTable.status,
@ -4305,6 +4307,7 @@ export function agentRoutes(
role: normalizedHireInput.role,
title: normalizedHireInput.title ?? null,
icon: normalizedHireInput.icon ?? null,
appearance: agent.appearance,
reportsTo: normalizedHireInput.reportsTo ?? null,
capabilities: normalizedHireInput.capabilities ?? null,
adapterType: requestedAdapterType,
@ -6278,6 +6281,7 @@ export function agentRoutes(
createdAt: heartbeatRuns.createdAt,
agentId: heartbeatRuns.agentId,
agentName: agentsTable.name,
agentAppearance: agentsTable.appearance,
adapterType: agentsTable.adapterType,
logBytes: heartbeatRuns.logBytes,
livenessState: heartbeatRuns.livenessState,
@ -6328,6 +6332,8 @@ export function agentRoutes(
const projections = await executionProjectionsForRuns(db, companyId, rows.map(run => run.id));
res.json(await Promise.all(rows.map(async (run) => runRedactions.redactForRun(companyId, run.id, {
...heartbeat.decorateActiveRunStatus(run),
agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId),
avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512),
execution: projections.get(run.id) ?? null,
outputSilence: await heartbeat.buildRunOutputSilence(run),
}))));
@ -6337,6 +6343,8 @@ export function agentRoutes(
const projections = await executionProjectionsForRuns(db, companyId, liveRuns.map(run => run.id));
res.json(await Promise.all(liveRuns.map(async (run) => runRedactions.redactForRun(companyId, run.id, {
...heartbeat.decorateActiveRunStatus(run),
agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId),
avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512),
execution: projections.get(run.id) ?? null,
outputSilence: await heartbeat.buildRunOutputSilence(run),
}))));
@ -6907,6 +6915,7 @@ export function agentRoutes(
createdAt: heartbeatRuns.createdAt,
agentId: heartbeatRuns.agentId,
agentName: agentsTable.name,
agentAppearance: agentsTable.appearance,
adapterType: agentsTable.adapterType,
logBytes: heartbeatRuns.logBytes,
livenessState: heartbeatRuns.livenessState,
@ -6934,6 +6943,8 @@ export function agentRoutes(
const projections = await executionProjectionsForRuns(db, issue.companyId, liveRuns.map(run => run.id));
res.json(await Promise.all(liveRuns.map(async (run) => ({
...heartbeat.decorateActiveRunStatus(run, { companyId: issue.companyId, issueId: issue.id }),
agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId),
avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512),
execution: projections.get(run.id) ?? null,
outputSilence: await heartbeat.buildRunOutputSilence({ ...run, companyId: issue.companyId }),
}))));
@ -6996,6 +7007,8 @@ export function agentRoutes(
execution: await executionProjectionForRun(db, issue.companyId, run.id),
agentId: agent.id,
agentName: agent.name,
agentAppearance: agent.appearance,
avatarUrl: agent.avatarUrl,
adapterType: agent.adapterType,
outputSilence: await heartbeat.buildRunOutputSilence({ ...run, companyId: issue.companyId }),
});

View File

@ -7,6 +7,10 @@ import { Router } from "express";
import { z } from "zod";
import {
// Agent
AGENT_PALETTE_IDS,
AGENT_AVATAR_SIZES,
CHARACTER_STATES,
agentAppearanceSchema,
createAgentSchema,
createAgentHireSchema,
updateAgentSchema,
@ -1237,6 +1241,7 @@ const RUNTIME_TOOLS_OPERATIONS = new Set([
]);
const PUBLIC_OPERATIONS = new Set([
"GET /api/agent-avatars/{version}/{palette}/{file}",
"GET /api/health",
"GET /api/openapi.json",
"GET /api/board-claim/{token}",
@ -2517,6 +2522,32 @@ for (const route of [
// ─── Agents ──────────────────────────────────────────────────────────────────
registry.register("AgentAppearance", agentAppearanceSchema);
registry.registerPath({
method: "get",
path: "/api/agent-avatars/{version}/{palette}/{file}",
tags: ["agents"],
summary: "Render or retrieve a public preset agent portrait",
description: "On-demand PNG artwork; no agent or company lookup. Logical size determines face detail independently of density. Successful URLs are immutable for one year and return a content-derived ETag. Cache entries regenerate after deletion.",
request: {
params: z.object({
version: z.literal("cap-v1"),
palette: z.enum([...AGENT_PALETTE_IDS, "muted-dream"]),
file: z.enum(CHARACTER_STATES.map(pose => `${pose}.png`)),
}),
query: z.object({
size: z.enum(AGENT_AVATAR_SIZES.map(String)).optional().default("512"),
scale: z.enum(["1", "2"]).optional().default("1"),
}).strict(),
},
responses: {
200: { description: "PNG portrait; Cache-Control: public, max-age=31536000, immutable; ETag: SHA-256 of PNG bytes", content: { "image/png": { schema: { type: "string", format: "binary" } } } },
304: { description: "If-None-Match matches the cached content ETag" },
400: r.badRequest,
503: { description: "Retryable rendering/storage failure; Cache-Control: no-store; Retry-After: 5" },
},
});
registry.registerPath({
method: "get",
path: "/api/companies/{companyId}/built-in-agents",

View File

@ -0,0 +1,79 @@
import { Worker } from "node:worker_threads";
import type { AgentAvatarRequest } from "./agent-avatars.js";
type Job = { request: AgentAvatarRequest; resolve: (png: Buffer) => void; reject: (error: Error) => void };
/** Lazy, bounded workers isolate geometry/rasterization from the API event loop. */
export function createAgentAvatarPool(concurrency = 2, maxQueue = 64) {
const queue: Job[] = [];
let active = 0;
const idle: Worker[] = [];
const timers = new Map<Worker, ReturnType<typeof setTimeout>>();
const workers = new Set<Worker>();
let closed = false;
function spawn() {
const source = import.meta.url.endsWith(".ts");
const url = new URL(source ? "./agent-avatar-worker.ts" : "./agent-avatar-worker.js", import.meta.url);
const worker = source
? new Worker(`import(${JSON.stringify(import.meta.resolve('tsx/esm/api'))}).then(({tsImport}) => tsImport(${JSON.stringify(url.href)}, ${JSON.stringify(import.meta.url)}));`, { eval: true })
: new Worker(url);
workers.add(worker);
// Idle worker failures must not become uncaught events or leave dead slots.
worker.on("error", () => {});
worker.on("exit", () => {
const index = idle.indexOf(worker);
if (index >= 0) idle.splice(index, 1);
clearTimeout(timers.get(worker)); timers.delete(worker); workers.delete(worker);
});
return worker;
}
function drain() {
while (!closed && active < concurrency && queue.length) {
const job = queue.shift()!;
let worker: Worker;
try { worker = idle.pop() ?? spawn(); }
catch (error) { job.reject(error instanceof Error ? error : new Error(String(error))); continue; }
clearTimeout(timers.get(worker)); timers.delete(worker);
worker.ref(); active++;
let finished = false;
const timeout = setTimeout(() => finish(new Error("Avatar rendering timed out")), 15_000);
const onError = (error: Error) => finish(error);
const onExit = () => finish(new Error("Avatar worker exited"));
const onMessage = (result: { png?: Uint8Array; error?: string }) => {
finish(result.png ? undefined : new Error(result.error ?? "Avatar rendering failed"), result.png);
};
function finish(error?: Error, png?: Uint8Array) {
if (finished) return;
finished = true; clearTimeout(timeout); active--;
worker.off("message", onMessage); worker.off("error", onError); worker.off("exit", onExit);
if (error || closed) {
workers.delete(worker); void worker.terminate();
job.reject(error ?? new Error("Avatar pool closed"));
} else {
job.resolve(Buffer.from(png!));
worker.unref(); idle.push(worker);
const timer = setTimeout(() => {
const index = idle.indexOf(worker);
if (index >= 0) idle.splice(index, 1);
timers.delete(worker); workers.delete(worker); void worker.terminate();
}, 30_000);
timer.unref(); timers.set(worker, timer);
}
drain();
}
worker.once("message", onMessage); worker.once("error", onError); worker.once("exit", onExit);
try { worker.postMessage(job.request); } catch (error) { finish(error instanceof Error ? error : new Error(String(error))); }
}
}
return {
render(request: AgentAvatarRequest): Promise<Buffer> {
if (closed || queue.length >= maxQueue) return Promise.reject(new Error("Avatar renderer is busy"));
return new Promise((resolve, reject) => { queue.push({ request, resolve, reject }); drain(); });
},
async close() {
closed = true;
for (const job of queue.splice(0)) job.reject(new Error("Avatar pool closed"));
for (const timer of timers.values()) clearTimeout(timer);
await Promise.all([...workers].map(worker => worker.terminate()));
},
};
}

View File

@ -0,0 +1,14 @@
import { parentPort } from "node:worker_threads";
import sharp from "sharp";
import { renderAgentSvg } from "@paperclipai/shared/cliplab/static";
import type { AgentAvatarRequest } from "./agent-avatars.js";
parentPort!.on("message", async (request: AgentAvatarRequest) => {
try {
const svg = renderAgentSvg(request.appearance, request.size, request.scale, request.pose, request.muted);
const png = await sharp(Buffer.from(svg)).png().toBuffer();
parentPort!.postMessage({ png });
} catch (error) {
parentPort!.postMessage({ error: error instanceof Error ? error.message : "Avatar rendering failed" });
}
});

View File

@ -0,0 +1,58 @@
import { createHash } from "node:crypto";
import type { AgentAppearance, AgentAvatarSize, CharacterState } from "@paperclipai/shared";
import type { StorageProvider } from "../storage/types.js";
import { createAgentAvatarPool } from "./agent-avatar-pool.js";
export interface AgentAvatarRequest {
appearance: AgentAppearance;
size: AgentAvatarSize;
scale: 1 | 2;
pose: CharacterState;
muted: boolean;
}
export function avatarCacheKey(request: AgentAvatarRequest) {
const { appearance, size, scale, pose, muted } = request;
return `generated-agent-avatars/${appearance.characterVersion}/${muted ? "muted-dream" : appearance.paletteId}/${pose}-${size}-${scale}.png`;
}
type CacheMetadata = { sha256: string; byteSize: number };
export function createAgentAvatarService(storage: StorageProvider, render?: (request: AgentAvatarRequest) => Promise<Buffer>) {
const pool = render ? undefined : createAgentAvatarPool();
const pending = new Map<string, Promise<CacheMetadata>>();
async function ensure(request: AgentAvatarRequest, key: string): Promise<CacheMetadata> {
const metadataKey = `${key}.json`;
const [image, metadata] = await Promise.all([
storage.headObject({ objectKey: key }), storage.headObject({ objectKey: metadataKey }),
]);
if (image.exists && metadata.exists) {
const object = await storage.getObject({ objectKey: metadataKey });
const chunks: Buffer[] = [];
for await (const chunk of object.stream) chunks.push(Buffer.from(chunk));
try {
const cached = JSON.parse(Buffer.concat(chunks).toString()) as CacheMetadata;
if (/^[a-f0-9]{64}$/.test(cached.sha256) && cached.byteSize > 0 && cached.byteSize === image.contentLength) return cached;
} catch { /* Disposable metadata: regenerate a corrupt or old cache entry. */ }
}
const bytes = await (render ?? pool!.render)(request);
const result = { sha256: createHash("sha256").update(bytes).digest("hex"), byteSize: bytes.length };
// Both providers publish whole objects atomically. Publish metadata last so
// readers never consider an unfinished image a completed cache entry.
await storage.putObject({ objectKey: key, body: bytes, contentLength: bytes.length, contentType: "image/png" });
const encoded = Buffer.from(JSON.stringify(result));
await storage.putObject({ objectKey: metadataKey, body: encoded, contentLength: encoded.length, contentType: "application/json" });
return result;
}
return {
async get(request: AgentAvatarRequest) {
const key = avatarCacheKey(request);
let result = pending.get(key);
if (!result) {
result = ensure(request, key).finally(() => pending.delete(key));
pending.set(key, result);
}
const metadata = await result;
const object = await storage.getObject({ objectKey: key });
return { stream: object.stream, byteSize: metadata.byteSize, etag: `"${metadata.sha256}"` };
},
async close() { await pool?.close(); },
};
}

View File

@ -1,3 +1,4 @@
import { agentAppearanceSchema, randomAgentAppearance, resolveAgentAppearance, agentAvatarUrl } from "@paperclipai/shared";
import { createHash, randomBytes } from "node:crypto";
import { and, desc, eq, gte, inArray, lt, ne, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@ -65,6 +66,7 @@ const CONFIG_REVISION_FIELDS = [
"role",
"title",
"icon",
"appearance",
"reportsTo",
"capabilities",
"adapterType",
@ -155,6 +157,7 @@ function buildConfigSnapshot(
role: row.role,
title: row.title,
icon: row.icon,
appearance: row.appearance,
reportsTo: row.reportsTo,
capabilities: row.capabilities,
adapterType: row.adapterType,
@ -190,6 +193,7 @@ function configPatchFromApprovalPayload(payload: Record<string, unknown>) {
const patch: Partial<typeof agents.$inferInsert> = {};
if (typeof payload.name === "string") patch.name = payload.name;
if (typeof payload.role === "string") patch.role = payload.role;
if (payload.appearance != null) patch.appearance = agentAppearanceSchema.parse(payload.appearance);
if (Object.prototype.hasOwnProperty.call(payload, "title")) {
patch.title = typeof payload.title === "string" ? payload.title : null;
}
@ -362,8 +366,11 @@ export function agentService(db: Db) {
const eligibilityAgents = allCompanyRows.map(toEligibilityAgent);
return rows.map((row) => {
const base = normalizeAgentBaseRow(row);
const appearance = resolveAgentAppearance(row.appearance, row.id);
return {
...base,
appearance,
avatarUrl: agentAvatarUrl(appearance),
orgChainHealth: getAgentWorkEligibility({
agent: toEligibilityAgent(row),
agents: eligibilityAgents,
@ -848,6 +855,7 @@ export function agentService(db: Db) {
.values({
...data,
name: uniqueName,
appearance: data.appearance == null ? randomAgentAppearance() : agentAppearanceSchema.parse(data.appearance),
companyId,
role,
adapterType,

View File

@ -1,3 +1,4 @@
import { agentAppearanceSchema } from "@paperclipai/shared";
import { and, asc, eq, inArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { approvalComments, approvals } from "@paperclipai/db";
@ -160,6 +161,7 @@ export function approvalService(db: Db) {
} else {
const created = await agentsSvc.create(updated.companyId, {
name: String(payload.name ?? "New Agent"),
appearance: payload.appearance == null ? undefined : agentAppearanceSchema.parse(payload.appearance),
role: String(payload.role ?? "general"),
title: typeof payload.title === "string" ? payload.title : null,
reportsTo: typeof payload.reportsTo === "string" ? payload.reportsTo : null,

View File

@ -1,3 +1,4 @@
import { agentAppearanceSchema } from "@paperclipai/shared";
import { createHash, randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import { execFile } from "node:child_process";
@ -702,6 +703,7 @@ type ProjectLike = {
targetDate: string | null;
color: string | null;
icon: string | null;
appearance?: import("@paperclipai/shared").AgentAppearance | null;
status: string;
env: Record<string, unknown> | null;
executionWorkspacePolicy: Record<string, unknown> | null;
@ -3238,6 +3240,7 @@ function buildManifestFromPackageFiles(
role: asString(extension.role) ?? asString(frontmatter.role) ?? "agent",
title,
icon: asString(extension.icon),
appearance: extension.appearance == null ? undefined : agentAppearanceSchema.parse(extension.appearance),
capabilities: asString(extension.capabilities),
reportsToSlug: asString(frontmatter.reportsTo) ?? asString(extension.reportsTo),
reportsToExistingAgentId: asString(extension.reportsToExistingAgentId),
@ -4254,6 +4257,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const extension = stripEmptyValues({
role: agent.role !== "agent" ? agent.role : undefined,
icon: agent.icon ?? null,
appearance: agent.appearance,
capabilities: agent.capabilities ?? null,
adapter: {
type: agent.adapterType,
@ -5592,6 +5596,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
role: manifestAgent.role,
title: manifestAgent.title,
icon: manifestAgent.icon,
...(manifestAgent.appearance ? { appearance: manifestAgent.appearance } : {}),
capabilities: manifestAgent.capabilities,
reportsTo: null,
adapterType: normalizedAdapter.adapterType,

View File

@ -1,3 +1,4 @@
import { agentAvatarUrl, resolveAgentAppearance } from "@paperclipai/shared";
import { and, desc, eq, gte, isNotNull, isNull, lt, lte, sql } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import type { Db } from "@paperclipai/db";
@ -282,10 +283,11 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
return db
const rows = await db
.select({
agentId: costEvents.agentId,
agentName: agents.name,
agentAppearance: agents.appearance,
agentStatus: agents.status,
costCents: sumAsNumber(costEvents.costCents),
inputTokens: sumAsNumber(costEvents.inputTokens),
@ -305,8 +307,12 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
.from(costEvents)
.leftJoin(agents, eq(costEvents.agentId, agents.id))
.where(and(...conditions))
.groupBy(costEvents.agentId, agents.name, agents.status)
.groupBy(costEvents.agentId, agents.name, agents.appearance, agents.status)
.orderBy(desc(sumAsNumber(costEvents.costCents)));
return rows.map(row => {
const appearance = resolveAgentAppearance(row.agentAppearance, row.agentId);
return { ...row, agentAppearance: appearance, avatarUrl: agentAvatarUrl(appearance, 512) };
});
},
byProvider: async (companyId: string, range?: CostDateRange) => {
@ -431,10 +437,11 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
// the (companyId, agentId, occurredAt) composite index covers this well.
// order by provider + model for stable db-level ordering; cost-desc sort
// within each agent's sub-rows is done client-side in the ui memo.
return db
const rows = await db
.select({
agentId: costEvents.agentId,
agentName: agents.name,
agentAppearance: agents.appearance,
provider: costEvents.provider,
biller: costEvents.biller,
billingType: costEvents.billingType,
@ -450,12 +457,17 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
.groupBy(
costEvents.agentId,
agents.name,
agents.appearance,
costEvents.provider,
costEvents.biller,
costEvents.billingType,
costEvents.model,
)
.orderBy(costEvents.provider, costEvents.biller, costEvents.billingType, costEvents.model);
return rows.map(row => {
const appearance = resolveAgentAppearance(row.agentAppearance, row.agentId);
return { ...row, agentAppearance: appearance, avatarUrl: agentAvatarUrl(appearance, 512) };
});
},
byProject: async (companyId: string, range?: CostDateRange) => {

View File

@ -1,3 +1,4 @@
import { withAgentAppearance } from "@paperclipai/shared";
import { and, count, desc, eq, gte, inArray, lte, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
@ -479,10 +480,10 @@ export function createSecretProposalsService(db: Db) {
async function enrich(proposal: Proposal) {
const [proposer, target, originIssue, secret, secretProposal] = await Promise.all([
db.select({ id: agents.id, name: agents.name, icon: agents.icon }).from(agents)
db.select({ id: agents.id, name: agents.name, icon: agents.icon, appearance: agents.appearance }).from(agents)
.where(eq(agents.id, proposal.proposedByAgentId)).then((rows) => rows[0] ?? null),
proposal.targetId
? db.select({ id: agents.id, name: agents.name, icon: agents.icon }).from(agents)
? db.select({ id: agents.id, name: agents.name, icon: agents.icon, appearance: agents.appearance }).from(agents)
.where(eq(agents.id, proposal.targetId)).then((rows) => rows[0] ?? null)
: Promise.resolve(null),
proposal.originIssueId
@ -510,8 +511,8 @@ export function createSecretProposalsService(db: Db) {
...safe,
secretName: secret?.name ?? null,
secretProposalName: secretProposal?.proposedName ?? null,
proposedBy: proposer,
target,
proposedBy: withAgentAppearance(proposer),
target: target ? withAgentAppearance(target) : null,
originIssue,
};
}

View File

@ -1,3 +1,4 @@
import { withAgentAppearance } from "@paperclipai/shared";
import { and, asc, desc, eq, gte, inArray, isNull, lte, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
@ -430,7 +431,7 @@ export function workTimelineService(db: Db) {
const [agentRows, userRows] = await Promise.all([
agentIds.length > 0
? db
.select({ id: agents.id, name: agents.name, icon: agents.icon })
.select({ id: agents.id, name: agents.name, icon: agents.icon, appearance: agents.appearance })
.from(agents)
.where(and(eq(agents.companyId, companyId), inArray(agents.id, maybeUuidList(agentIds))))
: [],
@ -763,7 +764,8 @@ export function workTimelineService(db: Db) {
const [type, rawId] = id.split(":", 2) as [TimelineActorType, string];
if (type === "agent") {
const agent = actorMaps.agents.get(rawId);
return { id, type, name: agent?.name ?? "Unknown agent", avatar: agent?.icon ?? null };
const identity = withAgentAppearance(agent ?? { id: rawId });
return { id, type, name: agent?.name ?? "Unknown agent", avatar: identity.avatarUrl, appearance: identity.appearance, avatarUrl: identity.avatarUrl };
}
if (type === "user") {
const user = actorMaps.users.get(rawId);

View File

@ -0,0 +1,33 @@
import { useState } from "react";
import { agentAvatarUrl, resolveAgentAppearance, type AgentAppearance, type AgentAvatarSize, type CharacterState } from "@paperclipai/shared";
import { cn } from "@/lib/utils";
import { deriveInitials } from "./Identity";
export type AvatarAgent = { id?: string; name?: string; appearance?: AgentAppearance | null; avatarUrl?: string | null };
export const avatarSizeClasses: Record<AgentAvatarSize, string> = {
16: "size-4", 20: "size-5", 24: "size-6", 32: "size-8", 40: "size-10", 48: "size-12",
64: "size-16", 96: "size-24", 128: "size-32", 256: "size-64", 512: "size-128",
};
export interface AgentAvatarProps {
agent?: AvatarAgent | null;
appearance?: AgentAppearance | null;
size?: AgentAvatarSize;
name?: string;
label?: string;
pose?: CharacterState;
muted?: boolean;
className?: string;
}
export function AgentAvatar({ agent, appearance, size = 24, name, label, pose = "rest", muted = false, className }: AgentAvatarProps) {
const identity = resolveAgentAppearance(appearance ?? agent?.appearance, agent?.id);
const src = agentAvatarUrl(identity, size, 1, pose, muted);
const [failedUrl, setFailedUrl] = useState<string | null>(null);
return (
<span data-slot="agent-avatar" className={cn("relative inline-flex shrink-0 items-center justify-center align-middle", avatarSizeClasses[size], className)}
role={label ? "img" : undefined} aria-label={label} aria-hidden={label ? undefined : true}>
{failedUrl === src ? <span className="text-xs text-muted-foreground">{deriveInitials(name ?? agent?.name ?? "Agent")}</span> :
<img src={src} srcSet={`${agentAvatarUrl(identity, size, 2, pose, muted)} 2x`} alt="" width={size} height={size}
decoding="async" loading="lazy" className="size-full object-contain" onError={() => setFailedUrl(src)} />}
</span>
);
}

View File

@ -0,0 +1,63 @@
import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type RefObject } from "react";
import { resolveAgentAppearance, type CharacterState } from "@paperclipai/shared";
import { cn } from "@/lib/utils";
import { characterSlot } from "@/lib/agent-character-slot";
import { AgentAvatar, avatarSizeClasses, type AgentAvatarProps } from "./AgentAvatar";
import type { createCharacter } from "@paperclipai/shared/cliplab/runtime";
type Player = ReturnType<typeof createCharacter>;
export interface AgentCharacterProps extends Omit<AgentAvatarProps, "pose"> {
state?: CharacterState;
motion?: "auto" | "still";
trackingRegion?: RefObject<HTMLElement | null>;
trackingScope?: "region" | "page";
followCursor?: boolean;
followRotation?: boolean;
}
export function AgentCharacter({ agent, appearance, size = 256, state = "idle", muted = false, motion = "auto", trackingRegion, trackingScope = "region", followCursor = true, followRotation = true, className, label, name }: AgentCharacterProps) {
const identity = useMemo(() => resolveAgentAppearance(appearance ?? agent?.appearance, agent?.id), [appearance, agent?.appearance, agent?.id]);
const root = useRef<HTMLSpanElement>(null), host = useRef<HTMLSpanElement>(null), player = useRef<Player | null>(null);
const slotId = useRef(Symbol("agent-character"));
const owner = useSyncExternalStore(characterSlot.subscribe, characterSlot.getSnapshot, () => null);
const [visible, setVisible] = useState(false), [reduced, setReduced] = useState(true), [failed, setFailed] = useState(false), [ready, setReady] = useState(false);
const active = visible && !reduced && !failed && motion === "auto" && state !== "rest";
useEffect(() => {
if (typeof matchMedia !== "function" || typeof IntersectionObserver !== "function") return;
const media = matchMedia("(prefers-reduced-motion: reduce)");
const change = () => setReduced(media.matches); change(); media.addEventListener("change", change);
const observer = new IntersectionObserver(entries => setVisible(entries[0]?.isIntersecting ?? false));
if (root.current) observer.observe(root.current);
return () => { observer.disconnect(); media.removeEventListener("change", change); };
}, []);
useEffect(() => {
if (active && owner === null) characterSlot.acquire(slotId.current);
if (!active) characterSlot.release(slotId.current);
}, [active, owner]);
useEffect(() => () => characterSlot.release(slotId.current), []);
useEffect(() => {
if (!active || owner !== slotId.current) return;
let disposed = false;
setReady(false);
void Promise.all([import("@paperclipai/shared/cliplab/runtime"), import("@paperclipai/shared/cliplab/definition")]).then(([runtime, library]) => {
if (disposed || !host.current) return;
player.current = runtime.createCharacter(host.current, library.characterDefinition(identity, muted), {
animation: library.animationId(state), trackingRegion: trackingRegion?.current ?? root.current ?? undefined,
followCursor, followRotation, trackingScope, displaySize: size, onError: () => setFailed(true),
});
setReady(true);
}).catch(() => { if (!disposed) setFailed(true); });
return () => { disposed = true; player.current?.destroy(); player.current = null; setReady(false); };
}, [active, owner, trackingRegion, trackingScope, size, followCursor, followRotation]);
useEffect(() => {
if (!player.current) return;
void import("@paperclipai/shared/cliplab/definition").then(library => {
player.current?.setDefinition(library.characterDefinition(identity, muted));
player.current?.setAnimation(library.animationId(state));
});
}, [identity, muted, state, ready]);
return <span ref={root} role={label ? "img" : undefined} aria-label={label} aria-hidden={label ? undefined : true}
className={cn("relative inline-block shrink-0", avatarSizeClasses[size], className)}>
<AgentAvatar agent={agent} appearance={identity} size={size < 256 ? 256 : size} name={name} pose={state} muted={muted} className={cn("size-full", ready && "invisible")} />
<span ref={host} className="absolute inset-0" />
</span>;
}

View File

@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { createCharacter } from "@paperclipai/shared/cliplab/runtime";
import { characterDefinition } from "@paperclipai/shared/cliplab/definition";
import { appearanceForPalette } from "@paperclipai/shared";
const renderer = vi.hoisted(() => ({ render: vi.fn(), resize: vi.fn(), dispose: vi.fn() }));
vi.mock("@paperclipai/shared/cliplab/renderer", () => ({ CharacterRenderer: class { render = renderer.render; resize = renderer.resize; dispose = renderer.dispose; } }));
let intersect: (entries: any[]) => void;
let hidden = false;
let frames: Map<number, FrameRequestCallback>;
let reduced: MediaQueryList;
let coarse: MediaQueryList;
let target: HTMLDivElement;
let region: HTMLDivElement;
const players: Array<ReturnType<typeof createCharacter>> = [];
function media() { return Object.assign(new EventTarget(), { matches: false }) as unknown as MediaQueryList; }
beforeEach(() => {
vi.clearAllMocks(); renderer.render.mockReset(); hidden = false; frames = new Map(); reduced = media(); coarse = media();
let id = 0;
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { frames.set(++id, callback); return id; });
vi.stubGlobal("cancelAnimationFrame", (key: number) => frames.delete(key));
vi.stubGlobal("matchMedia", (query: string) => query.includes("reduced-motion") ? reduced : coarse);
vi.spyOn(document, "hidden", "get").mockImplementation(() => hidden);
vi.stubGlobal("IntersectionObserver", class { constructor(callback: typeof intersect) { intersect = callback; } observe() {} disconnect() {} });
vi.stubGlobal("ResizeObserver", class { observe() {} disconnect() {} });
target = document.createElement("div"); region = document.createElement("div"); region.appendChild(target); document.body.appendChild(region);
});
afterEach(() => { players.splice(0).forEach(player => player.destroy()); region.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
function create() {
const player = createCharacter(target, characterDefinition(appearanceForPalette("arctic-blue")), { trackingRegion: region });
players.push(player); return player;
}
it("stops scheduling offscreen, when hidden, for reduced motion, and after disposal", () => {
const player = create(); expect(frames.size).toBe(0);
intersect([{ isIntersecting: true }]); expect(frames.size).toBe(1);
hidden = true; document.dispatchEvent(new Event("visibilitychange")); expect(frames.size).toBe(0);
hidden = false; document.dispatchEvent(new Event("visibilitychange")); expect(frames.size).toBe(1);
Object.assign(reduced, { matches: true }); reduced.dispatchEvent(new Event("change")); expect(frames.size).toBe(0);
Object.assign(reduced, { matches: false }); reduced.dispatchEvent(new Event("change")); expect(frames.size).toBe(1);
intersect([{ isIntersecting: false }]); expect(frames.size).toBe(0);
intersect([{ isIntersecting: true }]); player.destroy(); player.destroy();
expect(frames.size).toBe(0); expect(renderer.dispose).toHaveBeenCalledTimes(1); expect(target.children).toHaveLength(0);
});
it("tracks only its region, ignores touch, and removes listeners for coarse pointers", () => {
const add = vi.spyOn(region, "addEventListener"), remove = vi.spyOn(region, "removeEventListener");
const documentAdd = vi.spyOn(document, "addEventListener");
create(); intersect([{ isIntersecting: true }]);
expect(add.mock.calls.some(([event]) => event === "pointermove")).toBe(true);
expect(documentAdd.mock.calls.some(([event]) => event === "pointermove")).toBe(false);
const before = renderer.render.mock.calls.at(-1)?.[3];
region.dispatchEvent(Object.assign(new Event("pointermove"), { pointerType: "touch", clientX: 100, clientY: 100 }));
const [id, frame] = [...frames][0]; frames.delete(id); frame(performance.now() + 16);
expect(renderer.render.mock.calls.at(-1)?.[3]).toEqual(before);
Object.assign(coarse, { matches: true }); coarse.dispatchEvent(new Event("change"));
expect(remove.mock.calls.some(([event]) => event === "pointermove")).toBe(true);
});
it("disposes the canvas and subscriptions when the first render fails", () => {
renderer.render.mockImplementationOnce(() => { throw new Error("lost context"); });
expect(() => create()).toThrow("lost context");
expect(renderer.dispose).toHaveBeenCalledTimes(1); expect(target.children).toHaveLength(0); expect(frames.size).toBe(0);
});
it("falls back when a later expression render fails", () => {
const onError = vi.fn();
const player = createCharacter(target, characterDefinition(appearanceForPalette("arctic-blue")), { onError });
players.push(player); intersect([{ isIntersecting: true }]);
renderer.render.mockImplementationOnce(() => { throw new Error("lost context"); });
expect(() => player.setAnimation("success")).not.toThrow();
expect(onError).toHaveBeenCalledOnce(); expect(frames.size).toBe(0);
});
it("can follow the whole page while measuring gaze from the character and cleaning up", () => {
const page = document.documentElement;
const remove = vi.spyOn(page, "removeEventListener");
vi.spyOn(target, "getBoundingClientRect").mockReturnValue({ left: 100, top: 100, width: 100, height: 100 } as DOMRect);
const player = createCharacter(target, characterDefinition(appearanceForPalette("arctic-blue")), { trackingScope: "page", displaySize: 256 });
players.push(player); intersect([{ isIntersecting: true }]);
// Outside the character and its parent region, but still on the page.
page.dispatchEvent(Object.assign(new Event("pointermove"), { pointerType: "mouse", clientX: 900, clientY: 150 }));
const [id, frame] = [...frames][0]; frames.delete(id); frame(performance.now() + 50);
const gaze = renderer.render.mock.calls.at(-1)?.[3];
expect(gaze.x).toBeGreaterThan(0); expect(gaze.y).toBe(0);
player.destroy();
expect(remove.mock.calls.some(([event]) => event === "pointermove")).toBe(true);
});

View File

@ -0,0 +1,9 @@
import { AgentAvatar, type AvatarAgent } from "./AgentAvatar";
import { cn } from "@/lib/utils";
export function AgentIdentity({ agent, size = "default", className }: { agent: AvatarAgent; size?: "xs" | "sm" | "default" | "lg"; className?: string }) {
const pixels = { xs: 20, sm: 24, default: 32, lg: 40 } as const;
return <span title={agent.name} className={cn("inline-flex min-w-0 items-center gap-1.5", className)}>
<AgentAvatar agent={agent} size={pixels[size]} />
<span className={cn("truncate", size === "sm" ? "text-xs" : "text-sm")}>{agent.name ?? "Agent"}</span>
</span>;
}

View File

@ -0,0 +1,78 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { appearanceForPalette } from "@paperclipai/shared";
import { AgentAvatar } from "./AgentAvatar";
import { AgentCharacter } from "./AgentCharacter";
import { useAgentAppearanceDraft } from "../hooks/useAgentAppearanceDraft";
const renderer = vi.hoisted(() => ({ destroy: vi.fn(), setDefinition: vi.fn(), setAnimation: vi.fn() }));
const createCharacter = vi.hoisted(() => vi.fn(() => renderer));
vi.mock("@paperclipai/shared/cliplab/runtime", () => ({ createCharacter }));
vi.mock("@paperclipai/shared/cliplab/definition", () => ({ characterDefinition: vi.fn(value => value), animationId: vi.fn(value => value) }));
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root;
let host: HTMLDivElement;
let observers: Array<(entries: any[]) => void>;
let reduced = false;
const appearance = appearanceForPalette("bubblegum-sky");
beforeEach(() => {
vi.clearAllMocks(); observers = []; reduced = false;
sessionStorage.clear();
host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host);
vi.stubGlobal("IntersectionObserver", class {
constructor(callback: (entries: any[]) => void) { observers.push(callback); }
observe() {} disconnect() {}
});
vi.stubGlobal("matchMedia", () => ({ matches: reduced, addEventListener() {}, removeEventListener() {} }));
});
afterEach(async () => { await act(async () => root.unmount()); host.remove(); vi.unstubAllGlobals(); });
async function show() { await act(async () => { for (const observer of observers) observer([{ isIntersecting: true }]); }); }
describe("agent persona presentation", () => {
it("renders 500 static images without initializing a live renderer", async () => {
await act(async () => root.render(<>{Array.from({ length: 500 }, (_, i) => <AgentAvatar key={i} appearance={appearance} size={24} />)}</>));
expect(host.querySelectorAll("img")).toHaveLength(500);
expect(host.querySelector("canvas")).toBeNull();
expect(createCharacter).not.toHaveBeenCalled();
expect(host.querySelector("img")?.getAttribute("srcset")).toContain("size=24&scale=2");
expect(host.querySelector("img")?.getAttribute("width")).toBe("24");
});
it("keeps reserved image dimensions and falls back to initials after an image error", async () => {
await act(async () => root.render(<AgentAvatar appearance={appearance} name="Chief of Staff" size={48} label="Chief of Staff" />));
expect(host.querySelector("img")?.height).toBe(48);
await act(async () => { host.querySelector("img")!.dispatchEvent(new Event("error")); });
expect(host.textContent).toBe("CS");
expect(host.querySelector('[role="img"]')?.getAttribute("aria-label")).toBe("Chief of Staff");
});
it("allows only one live character, releases it offscreen and disposes on unmount", async () => {
await act(async () => root.render(<><AgentCharacter appearance={appearance} /><AgentCharacter appearance={appearance} /></>));
expect(createCharacter).not.toHaveBeenCalled();
await show();
expect(createCharacter).toHaveBeenCalledTimes(1);
await act(async () => { observers[0]([{ isIntersecting: false }]); });
expect(renderer.destroy).toHaveBeenCalledTimes(1);
expect(createCharacter).toHaveBeenCalledTimes(2);
await act(async () => root.render(null));
expect(renderer.destroy).toHaveBeenCalledTimes(2);
});
it("uses only static images for reduced motion and an explicit still policy", async () => {
reduced = true;
await act(async () => root.render(<><AgentCharacter appearance={appearance} /><AgentCharacter appearance={appearance} motion="still" /></>));
await show();
expect(createCharacter).not.toHaveBeenCalled();
expect(host.querySelectorAll("img")).toHaveLength(2);
});
it("retains the draft assignment across remounts and clears it only after creation", async () => {
let draft!: ReturnType<typeof useAgentAppearanceDraft>;
function Draft() { draft = useAgentAppearanceDraft("company:new-agent"); return null; }
await act(async () => root.render(<Draft />));
const first = draft.appearance;
await act(async () => root.render(null));
await act(async () => root.render(<Draft />));
expect(draft.appearance).toEqual(first);
expect(JSON.parse(sessionStorage.getItem("paperclip.agent-appearance.company:new-agent")!)).toEqual(first);
draft.clear();
expect(sessionStorage.getItem("paperclip.agent-appearance.company:new-agent")).toBeNull();
});
});

View File

@ -0,0 +1,17 @@
import { useState } from "react";
import { agentAppearanceSchema, randomAgentAppearance } from "@paperclipai/shared";
/** Non-secret visual identity only. The caller remounts when its draft key changes. */
export function useAgentAppearanceDraft(draftKey: string) {
const key = `paperclip.agent-appearance.${draftKey}`;
const [appearance] = useState(() => {
try {
const stored = agentAppearanceSchema.safeParse(JSON.parse(sessionStorage.getItem(key) ?? "null"));
if (stored.success) return stored.data;
} catch { /* Storage can be unavailable; retain the in-memory assignment. */ }
const value = randomAgentAppearance();
try { sessionStorage.setItem(key, JSON.stringify(value)); } catch { /* In-memory draft still works. */ }
return value;
});
return { appearance, clear() { try { sessionStorage.removeItem(key); } catch { /* Best effort. */ } } };
}

View File

@ -2912,3 +2912,43 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
.task-chat-loading-shell .animate-pulse {
animation: none;
}
/* Immutable ClipLab cap-v1 artwork tokens. New artwork gets a new version. */
:root {
--agent-cap-v1-bubblegum-sky-a: #8bd4ff;
--agent-cap-v1-bubblegum-sky-b: #ff51bf;
--agent-cap-v1-pink-lemonade-a: #ff26a8;
--agent-cap-v1-pink-lemonade-b: #fff78a;
--agent-cap-v1-orchid-peach-a: #e771ff;
--agent-cap-v1-orchid-peach-b: #ffd87c;
--agent-cap-v1-coral-mint-a: #beffe8;
--agent-cap-v1-coral-mint-b: #ff797b;
--agent-cap-v1-lime-lagoon-a: #b4ffa4;
--agent-cap-v1-lime-lagoon-b: #26dfff;
--agent-cap-v1-arctic-blue-a: #97fff3;
--agent-cap-v1-arctic-blue-b: #0084ff;
--agent-cap-v1-solar-flare-a: #ecca5c;
--agent-cap-v1-solar-flare-b: #fe3c3f;
--agent-cap-v1-violet-ember-a: #ff5c43;
--agent-cap-v1-violet-ember-b: #6262ff;
--agent-cap-v1-deep-tide-a: #003d60;
--agent-cap-v1-deep-tide-b: #32fffc;
--agent-cap-v1-coral-current-a: #26e2ff;
--agent-cap-v1-coral-current-b: #ff6666;
--agent-cap-v1-golden-hour-a: #ffcd4f;
--agent-cap-v1-golden-hour-b: #fde5ba;
--agent-cap-v1-tangerine-cobalt-a: #ffad32;
--agent-cap-v1-tangerine-cobalt-b: #4169ff;
--agent-cap-v1-electric-grove-a: #b4ff32;
--agent-cap-v1-electric-grove-b: #008d58;
--agent-cap-v1-flamingo-jade-a: #ff4f9a;
--agent-cap-v1-flamingo-jade-b: #36edaa;
--agent-cap-v1-cherry-pop-a: #ff69db;
--agent-cap-v1-cherry-pop-b: #d51c46;
--agent-cap-v1-turquoise-cherry-a: #37f0db;
--agent-cap-v1-turquoise-cherry-b: #ff4f64;
--agent-cap-v1-ultraviolet-tide-a: #7516cf;
--agent-cap-v1-ultraviolet-tide-b: #00e8d1;
--agent-cap-v1-muted-dream-a: #a6aaad;
--agent-cap-v1-muted-dream-b: #44464a;
}

View File

@ -0,0 +1,9 @@
// The app gets one live hero. Other placements retain their static image.
let owner: symbol | null = null;
const listeners = new Set<() => void>();
export const characterSlot = {
subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
getSnapshot() { return owner; },
acquire(id: symbol) { if (owner) return false; owner = id; for (const listener of listeners) listener(); return true; },
release(id: symbol) { if (owner !== id) return; owner = null; for (const listener of listeners) listener(); },
};

View File

@ -47,6 +47,11 @@ const baseAgent: AgentDetail = {
};
describe("duplicate agent payload", () => {
it("omits the source persona so central creation makes a fresh assignment", () => {
const source = { ...baseAgent, appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "arctic-blue" } } as AgentDetail;
expect(buildDuplicateAgentPayload(source)).not.toHaveProperty("appearance");
});
it("suffixes duplicate names", () => {
expect(duplicateAgentName("Senior Product Engineer")).toBe("Senior Product Engineer Copy");
expect(duplicateAgentName(" ")).toBe("Agent Copy");

View File

@ -1,4 +1,7 @@
import { SavedProviderKeySelect } from "../components/onboarding/SavedProviderKeySelect";
import { AgentAvatar } from "@/components/AgentAvatar";
import { AgentCharacter } from "@/components/AgentCharacter";
import { AGENT_PALETTE_IDS, appearanceForPalette } from "@paperclipai/shared";
import { RepositoryEditor } from "@/components/RepositoryEditor";
import { TaskChatMarker } from "@/components/task-chat/TaskChatMarker";
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
@ -1452,20 +1455,29 @@ export function DesignGuide() {
{/* ============================================================ */}
{/* IDENTITY */}
{/* ============================================================ */}
<Section title="Identity">
<Section title="Agent personas">
<SubSection title="Stable palette identities">
<div className="flex flex-wrap gap-3">{AGENT_PALETTE_IDS.map(palette => <AgentAvatar key={palette} appearance={appearanceForPalette(palette)} size={48} label={palette} />)}</div>
</SubSection>
<SubSection title="Onboarding and live character">
<p className="text-sm text-muted-foreground">Place one live character beside the agent name. Onboarding uses a larger padded frame. Onboarding and agent headers follow the pointer across the page; other placements track within their region. Full-page examples are in Storybook under Agents / Personas / Full pages.</p>
<div className="flex gap-4"><AgentCharacter muted state="sleepy" motion="still" size={128} /><AgentCharacter size={128} /></div>
</SubSection>
</Section>
<Section title="Human identity">
<SubSection title="Sizes">
<div className="flex items-center gap-6">
<Identity name="Agent Alpha" size="sm" />
<Identity name="Agent Alpha" />
<Identity name="Agent Alpha" size="lg" />
<Identity name="Alex Morgan" size="sm" />
<Identity name="Alex Morgan" />
<Identity name="Alex Morgan" size="lg" />
</div>
</SubSection>
<SubSection title="Initials derivation">
<div className="flex flex-col gap-2">
<Identity name="CEO Agent" size="sm" />
<Identity name="Casey Jordan" size="sm" />
<Identity name="Alpha" size="sm" />
<Identity name="Quality Assurance Lead" size="sm" />
<Identity name="Quinn Lee" size="sm" />
</div>
</SubSection>

View File

@ -20,6 +20,7 @@ const config: StorybookConfig = {
viteFinal: async (baseConfig) =>
mergeConfig(baseConfig, {
plugins: [tailwindcss()],
server: { proxy: { "/api/agent-avatars": { target: process.env.PAPERCLIP_STORYBOOK_API_URL ?? "http://localhost:3100", changeOrigin: true } } },
optimizeDeps: { include: ["motion/react", "react", "react-dom"] },
resolve: {
// Storybook's core and the react-vite builder each resolve their own

View File

@ -0,0 +1,130 @@
import { agentAvatarUrl } from "@paperclipai/shared";
import { expect, waitFor } from "storybook/test";
import { useEffect, useRef, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AGENT_PALETTE_IDS, AGENT_AVATAR_SIZES, CHARACTER_STATES, appearanceForPalette, type CharacterState } from "@paperclipai/shared";
import { AgentAvatar, avatarSizeClasses } from "../../src/components/AgentAvatar";
import { AgentCharacter } from "../../src/components/AgentCharacter";
import { AgentIdentity } from "../../src/components/AgentIdentity";
import { Button } from "../../src/components/ui/button";
const appearance = appearanceForPalette("bubblegum-sky");
const agent = { id: "storybook-agent", name: "Chief of Staff", appearance };
const meta = {
title: "Agents/Personas",
component: AgentCharacter,
args: { appearance, size: 256, state: "listening", label: "Chief of Staff" },
parameters: { docs: { description: { component: "Persistent cap-v1 identities. Avatars request on-demand PNGs from Paperclip; the hero alone loads ClipLab. Run Paperclip locally, or set PAPERCLIP_STORYBOOK_API_URL to an isolated API when starting Storybook. Static Storybook hosting must route /api/agent-avatars to Paperclip. No images are baked into Storybook." } } },
argTypes: {
state: { control: "select", options: CHARACTER_STATES },
size: { control: "select", options: AGENT_AVATAR_SIZES },
motion: { control: "select", options: ["auto", "still"] },
trackingScope: { control: "select", options: ["region", "page"] },
},
} satisfies Meta<typeof AgentCharacter>;
export default meta;
type Story = StoryObj<typeof meta>;
export const LiveCharacter: Story = {};
export const ReducedMotion: Story = { args: { motion: "still" } };
export const GrayBeforeConnection: Story = { args: { muted: true, state: "sleepy" } };
export const Palettes: Story = {
render: () => <div className="grid grid-cols-4 gap-6">{AGENT_PALETTE_IDS.map(palette => <div key={palette} className="flex flex-col items-center gap-2">
<AgentAvatar appearance={appearanceForPalette(palette)} size={96} label={palette} /><span className="text-xs">{palette}</span>
</div>)}</div>,
};
export const Sizes: Story = {
render: () => <div className="flex flex-wrap items-end gap-4">{AGENT_AVATAR_SIZES.map(size => <div key={size} className="flex flex-col items-center gap-2">
<AgentAvatar agent={agent} size={size} /><span className="text-xs">{size} px</span>
</div>)}</div>,
};
export const Expressions: Story = {
render: () => <div className="grid grid-cols-3 gap-4">{CHARACTER_STATES.map(state => <div key={state} className="flex flex-col items-center gap-2">
<AgentCharacter appearance={appearance} state={state} motion="still" size={128} /><span className="text-xs">{state}</span>
</div>)}</div>,
};
export const LightAndDark: Story = {
render: () => <div className="flex gap-4">{["light", "dark"].map(theme => <div key={theme} className={`${theme} bg-background p-6 text-foreground`}>
<AgentIdentity agent={agent} /><AgentAvatar agent={agent} size={128} />
</div>)}</div>,
};
export const StaticAndLive: Story = {
render: () => <div className="flex items-center gap-8"><AgentAvatar agent={agent} pose="listening" size={256} /><AgentCharacter agent={agent} state="listening" /></div>,
};
function Onboarding() {
const [state, setState] = useState<CharacterState>("sleepy");
const region = useRef<HTMLDivElement>(null);
return <div ref={region} className="flex max-w-lg flex-col items-center gap-4 p-6">
<AgentCharacter appearance={appearance} state={state} muted={state === "sleepy" || state === "loading"} trackingRegion={region} />
<h2 className="text-lg font-semibold">{state === "success" ? "Ready to work" : "Connect your agent"}</h2>
<div className="flex gap-2"><Button onClick={() => setState("loading")}>Connecting</Button><Button onClick={() => setState("success")}>Connection succeeded</Button><Button variant="outline" onClick={() => setState("sleepy")}>Reset</Button></div>
</div>;
}
export const OnboardingTransition: Story = { render: () => <Onboarding /> };
export const AppPlacements: Story = {
render: () => <div className="grid max-w-3xl gap-6">
<section className="space-y-3"><h2 className="text-lg font-semibold">Agents</h2>{AGENT_PALETTE_IDS.slice(0, 4).map((palette, i) => <div className="flex items-center justify-between gap-4" key={palette}>
<AgentIdentity agent={{ id: palette, name: ["Chief of Staff", "Researcher", "Designer", "Engineer"][i], appearance: appearanceForPalette(palette) }} /><span className="text-xs text-muted-foreground">Idle</span>
</div>)}</section>
<section className="space-y-2"><h2 className="text-lg font-semibold">Task conversation</h2><AgentIdentity agent={agent} size="sm" /><p className="text-sm">The launch brief is ready for review.</p></section>
<section className="flex items-center justify-between gap-4"><span className="text-sm">Prepare the launch brief</span><AgentAvatar agent={agent} size={20} label="Assigned to Chief of Staff" /></section>
<section className="flex items-center gap-6"><AgentCharacter agent={agent} size={128} /><div><h2 className="text-lg font-semibold">Agent configuration</h2><p className="text-sm text-muted-foreground">Chief of Staff · Connected</p></div></section>
</div>,
};
export const FiveHundredStaticAvatars: Story = {
render: () => <div className="grid grid-cols-10 gap-2">{Array.from({ length: 500 }, (_, i) => <AgentAvatar key={i} appearance={appearanceForPalette(AGENT_PALETTE_IDS[i % AGENT_PALETTE_IDS.length])} size={24} />)}</div>,
};
export const LegacyIdentity: Story = { render: () => <AgentIdentity agent={{ id: "legacy-agent", name: "Existing agent" }} /> };
function Lifecycle() {
const [mounted, setMounted] = useState(true);
return <div className="space-y-4"><Button onClick={() => setMounted(value => !value)}>Toggle character</Button>{mounted && <AgentCharacter agent={agent} />}</div>;
}
export const MountAndUnmount: Story = { render: () => <Lifecycle /> };
export const OneLiveRenderer: Story = { render: () => <div className="flex gap-6"><AgentCharacter agent={agent} /><AgentCharacter appearance={appearanceForPalette("arctic-blue")} /></div> };
export const ImageFailure: Story = {
render: () => <AgentAvatar appearance={appearance} name="Chief of Staff" size={64} />,
play: async ({ canvasElement }) => {
// Exercise the actual image error handler without a custom product URL API.
canvasElement.querySelector("img")?.dispatchEvent(new Event("error"));
await waitFor(() => expect(canvasElement.textContent).toContain("CS"));
},
};
export const CacheMissLoading: Story = {
render: () => <div className="flex items-center gap-4"><AgentAvatar agent={agent} size={64} /><span className="text-sm">The image slot keeps its dimensions while Paperclip renders a cold cache entry.</span></div>,
};
function WebGLFailure() {
const region = useRef<HTMLDivElement>(null);
return <div ref={region} className="space-y-4"><AgentCharacter agent={agent} />
<Button onClick={() => region.current?.querySelector("canvas")?.dispatchEvent(new Event("webglcontextlost", { cancelable: true }))}>Simulate WebGL loss</Button>
</div>;
}
export const RenderFailure: Story = { render: () => <WebGLFailure /> };
export const PointerScope: Story = {
render: () => <div className="flex items-start gap-8"><Onboarding /><p className="text-sm">Pointer movement outside the connection panel does not move the character. Touch and reduced motion disable tracking.</p></div>,
};
// A seeked WebGL frame lets Linux visual tests compare the identical front-facing
// sample with the server SVG/raster path. It schedules no animation frames.
type SnapshotPairProps = { size?: typeof AGENT_AVATAR_SIZES[number]; state?: CharacterState; density?: 1 | 2 };
function SnapshotPair({ size = 256, state = "rest", density = 2 }: SnapshotPairProps) {
const host = useRef<HTMLSpanElement>(null);
useEffect(() => {
let disposed = false;
let cleanup: (() => void) | undefined;
void Promise.all([import("@paperclipai/shared/cliplab/renderer"), import("@paperclipai/shared/cliplab/definition")]).then(([{ CharacterRenderer }, library]) => {
if (disposed || !host.current) return;
const canvas = document.createElement("canvas");
canvas.className = "size-full";
const renderer = new CharacterRenderer(canvas, { width: size, height: size, displaySize: size, pixelRatio: density });
const definition = library.characterDefinition(appearance);
renderer.render({ ...definition.character, trueFront: true, lockPosition: true, followCursor: false, followRotation: false }, library.characterStill(definition, state), { rotation: { x: 0, y: 0, z: 0 } });
host.current.appendChild(canvas);
cleanup = () => { renderer.dispose(); canvas.remove(); };
});
return () => { disposed = true; cleanup?.(); };
}, [size, state, density]);
return <div className="flex items-start gap-8">
<div className="space-y-3"><span data-testid="static-frame" className={`inline-block ${avatarSizeClasses[size]}`}><img src={agentAvatarUrl(appearance, size, density, state)} width={size} height={size} className="size-full object-contain" alt="Static PNG portrait" /></span><p className="text-xs text-muted-foreground">PNG · {size * density} × {size * density}</p></div>
<div className="space-y-3"><span data-testid="live-frame" ref={host} className={`inline-block ${avatarSizeClasses[size]}`} /><p className="text-xs text-muted-foreground">WebGL · {size * density} × {size * density}</p></div>
</div>;
}
export const SnapshotAgreement = { args: { size: 256, state: "rest", density: 2 }, argTypes: { density: { control: "inline-radio", options: [1, 2] } }, render: (args: SnapshotPairProps) => <SnapshotPair {...args} /> } satisfies StoryObj<SnapshotPairProps>;