/** * The Oo avatar value: one string that describes how a bot looks. * * It travels everywhere a bot's `color` already travels (bot rows, group members, * mentions, inbox), so every surface can draw the full look without new fields. * * Formats, newest first: * - `oo1:c=A97EFE;sp=cat;ew=round` — the full look; only non-default fields are written. * - `#8B5CF6::shape_3` — legacy colour + classic shape index. * - `#8B5CF6` or a palette id such as `violet` — legacy colour; shape follows the identity. * - `data:image/...` — an uploaded picture, not an Oo look. */ export const OO_SPECIES = [ "classic", "cat", "dog", "bunny", "bear", "robot", "ghost", "frog", "penguin", ] as const; export const OO_ARMS = ["glass", "stub", "paw", "robot", "none"] as const; export const OO_LASHES = ["none", "soft", "full", "flick"] as const; export const OO_LINERS = ["none", "wing"] as const; export const OO_EYESHADOWS = ["none", "rose", "lilac", "gold", "mint"] as const; export const OO_IRISES = ["classic", "brown", "blue", "green", "violet"] as const; export const OO_SPARKLES = ["dot", "star", "heart"] as const; export const OO_BROWS = ["auto", "fine", "bushy"] as const; export const OO_CHEEKS = ["none", "pink", "peach", "freckles"] as const; export const OO_EYEWEAR = ["none", "round", "nerd", "sun", "heart", "monocle", "reading"] as const; export const OO_HEADWEAR = [ "none", "chef", "crown", "headphones", "bow", "beanie", "flower", "party", ] as const; export const OO_NECKWEAR = ["none", "bowtie", "scarf", "bell"] as const; /** Classic silhouettes, in the order of the legacy `::shape_N` index. */ export const OO_CLASSIC_SHAPE_COUNT = 8; export const OO_ENERGY_RANGE = { min: 0.5, max: 1.6 } as const; export type OoSpecies = (typeof OO_SPECIES)[number]; export type OoArms = (typeof OO_ARMS)[number]; export type OoLashes = (typeof OO_LASHES)[number]; export type OoLiner = (typeof OO_LINERS)[number]; export type OoEyeshadow = (typeof OO_EYESHADOWS)[number]; export type OoIris = (typeof OO_IRISES)[number]; export type OoSparkle = (typeof OO_SPARKLES)[number]; export type OoBrows = (typeof OO_BROWS)[number]; export type OoCheeks = (typeof OO_CHEEKS)[number]; export type OoEyewear = (typeof OO_EYEWEAR)[number]; export type OoHeadwear = (typeof OO_HEADWEAR)[number]; export type OoNeckwear = (typeof OO_NECKWEAR)[number]; export interface OoAvatar { species: OoSpecies; /** `#RRGGBB` for `oo1:` values; legacy values keep whatever colour they stored. */ color: string; /** Classic silhouette index; `null` lets the identity pick one. */ shape: number | null; arms: OoArms; lashes: OoLashes; liner: OoLiner; eyeshadow: OoEyeshadow; iris: OoIris; sparkle: OoSparkle; brows: OoBrows; cheeks: OoCheeks; eyewear: OoEyewear; head: OoHeadwear; neck: OoNeckwear; /** Tempo and bounce of every animation. */ energy: number; /** 0–1: how often playful extras (ear twitch, tongue, tail wag) happen. */ playful: number; } export const DEFAULT_OO_AVATAR: OoAvatar = { species: "classic", color: "#8B5CF6", shape: null, arms: "glass", lashes: "none", liner: "none", eyeshadow: "none", iris: "classic", sparkle: "dot", brows: "auto", cheeks: "none", eyewear: "none", head: "none", neck: "none", energy: 1, playful: 0.5, }; export const OO_AVATAR_PREFIX = "oo1:"; /** Upper bound for an encoded `oo1:` value; every field written stays far below it. */ export const OO_AVATAR_MAX_LENGTH = 256; type EnumField = Exclude; const ENUM_FIELDS: readonly { field: EnumField; key: string; values: readonly string[] }[] = [ { field: "species", key: "sp", values: OO_SPECIES }, { field: "arms", key: "ar", values: OO_ARMS }, { field: "lashes", key: "la", values: OO_LASHES }, { field: "liner", key: "li", values: OO_LINERS }, { field: "eyeshadow", key: "es", values: OO_EYESHADOWS }, { field: "iris", key: "ir", values: OO_IRISES }, { field: "sparkle", key: "sk", values: OO_SPARKLES }, { field: "brows", key: "br", values: OO_BROWS }, { field: "cheeks", key: "ck", values: OO_CHEEKS }, { field: "eyewear", key: "ew", values: OO_EYEWEAR }, { field: "head", key: "hd", values: OO_HEADWEAR }, { field: "neck", key: "nk", values: OO_NECKWEAR }, ]; const HEX = /^#?([0-9a-f]{6})$/i; const SHORT_HEX = /^#([0-9a-f]{3})$/i; function normalizeHex(value: string): string | null { const short = SHORT_HEX.exec(value); if (short?.[1]) { const [r, g, b] = short[1]; return `#${r}${r}${g}${g}${b}${b}`.toUpperCase(); } const full = HEX.exec(value); return full?.[1] ? `#${full[1].toUpperCase()}` : null; } function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } function roundTo(value: number, step: number): number { return Math.round(value / step) * step; } /** Writes a look as an `oo1:` value. Colours that are not hex fall back to the default. */ export function encodeOoAvatar(avatar: OoAvatar): string { const color = normalizeHex(avatar.color) ?? DEFAULT_OO_AVATAR.color; const parts = [`c=${color.slice(1)}`]; for (const { field, key } of ENUM_FIELDS) { if (avatar[field] !== DEFAULT_OO_AVATAR[field]) parts.push(`${key}=${avatar[field]}`); } if (avatar.shape !== null) { parts.push(`sh=${clamp(Math.trunc(avatar.shape), 0, OO_CLASSIC_SHAPE_COUNT - 1)}`); } const energy = roundTo(clamp(avatar.energy, OO_ENERGY_RANGE.min, OO_ENERGY_RANGE.max), 0.05); if (energy !== DEFAULT_OO_AVATAR.energy) parts.push(`en=${energy.toFixed(2)}`); const playful = roundTo(clamp(avatar.playful, 0, 1), 0.05); if (playful !== DEFAULT_OO_AVATAR.playful) parts.push(`pl=${playful.toFixed(2)}`); return OO_AVATAR_PREFIX + parts.join(";"); } /** How an `oo1:` value is written, for models that compose a look as text. */ export function ooLookFormat(): string { const fields = ENUM_FIELDS.map( ({ field, key, values }) => `${key} ${field} (${values.join("|")})`, ); return `oo1: followed by key=value pairs joined by ";", e.g. oo1:c=F4A7B9;sp=cat;ew=round;hd=bow. c is the body colour as hex (required). Other keys: ${fields.join(", ")}. Omitted keys keep their defaults.`; } function decodeV1(body: string): OoAvatar | null { const avatar: OoAvatar = { ...DEFAULT_OO_AVATAR }; let hasColor = false; for (const pair of body.split(";")) { if (!pair) continue; const eq = pair.indexOf("="); if (eq <= 0) return null; const key = pair.slice(0, eq); const value = pair.slice(eq + 1); if (key === "c") { const color = normalizeHex(value); if (!color) return null; avatar.color = color; hasColor = true; continue; } if (key === "sh") { if (!/^\d$/.test(value) || Number(value) >= OO_CLASSIC_SHAPE_COUNT) return null; avatar.shape = Number(value); continue; } if (key === "en" || key === "pl") { if (!/^\d(?:\.\d{1,2})?$/.test(value)) return null; const number = Number(value); if (key === "en") { if (number < OO_ENERGY_RANGE.min || number > OO_ENERGY_RANGE.max) return null; avatar.energy = number; } else { if (number > 1) return null; avatar.playful = number; } continue; } const spec = ENUM_FIELDS.find((entry) => entry.key === key); // Unknown keys come from newer clients; keep the rest of the look. if (!spec) continue; if (!spec.values.includes(value)) return null; (avatar as unknown as Record)[spec.field] = value; } return hasColor ? avatar : null; } /** * Reads any stored avatar value as a look. Returns `null` for uploaded pictures and * for malformed `oo1:` values. */ export function decodeOoAvatar(value: string): OoAvatar | null { if (value.startsWith("data:")) return null; if (value.startsWith(OO_AVATAR_PREFIX)) { if (value.length > OO_AVATAR_MAX_LENGTH) return null; return decodeV1(value.slice(OO_AVATAR_PREFIX.length)); } const [color = "", shapePart] = value.split("::shape_"); let shape: number | null = null; if (shapePart !== undefined) { shape = /^\d+$/.test(shapePart) ? Number(shapePart) % OO_CLASSIC_SHAPE_COUNT : 0; if (!Number.isSafeInteger(shape)) shape = 0; } return { ...DEFAULT_OO_AVATAR, color: normalizeHex(color) ?? color, shape }; } /** A look written by a model, as the canonical `oo1:` value, or `null` when it is not one. */ export function normalizeOoLook(value: unknown): string | null { if (typeof value !== "string") return null; const raw = value.trim(); const look = raw.startsWith(OO_AVATAR_PREFIX) ? decodeOoAvatar(raw) : null; return look ? encodeOoAvatar(look) : null; } /** True when a stored avatar value is acceptable to persist. */ export function isValidAvatarValue(value: string): boolean { if (!value.startsWith(OO_AVATAR_PREFIX)) return true; return decodeOoAvatar(value) !== null; } /** * The colour part of an avatar value, for places that need a plain CSS colour * (cursors, rings, chips). Empty for pictures and unreadable values. */ export function ooAvatarColor(value: string): string { return decodeOoAvatar(value)?.color ?? ""; } /** * Oo eye anchors in the 200×200 avatar rig. Every species draws the eyes here, so the * pair never moves when the body changes. The small eye is the big one divided by φ. */ export const OO_GOLDEN_RATIO = (1 + Math.sqrt(5)) / 2; /** Sclera center distance ÷ sum of radii in `apps/web/public/evoo-logo.svg` (slight overlap). */ export const OO_EYE_CENTER_SPACING_RATIO = 55 / (34 + 24.5); /** Small-eye vertical drop as a fraction of the big sclera radius (same logo). */ export const OO_EYE_SMALL_VERTICAL_OFFSET_RATIO = (60 - 52) / 34; /** Place the small eye from the big anchor using the logo spacing ratio. */ export function ooEyeCentersFromLogoRatio( big: Readonly<{ cx: number; cy: number; r: number }>, smallR: number, ): { cx: number; cy: number } { const centerDistance = OO_EYE_CENTER_SPACING_RATIO * (big.r + smallR); return { cx: big.cx + centerDistance, cy: big.cy + OO_EYE_SMALL_VERTICAL_OFFSET_RATIO * big.r, }; } const OO_RIG_EYE_BIG = { cx: 80, cy: 108, r: 21 } as const; const OO_RIG_EYE_SMALL_R = OO_RIG_EYE_BIG.r / OO_GOLDEN_RATIO; const OO_RIG_EYE_SMALL_CENTER = ooEyeCentersFromLogoRatio(OO_RIG_EYE_BIG, OO_RIG_EYE_SMALL_R); export const OO_RIG_EYES = { big: OO_RIG_EYE_BIG, small: { ...OO_RIG_EYE_SMALL_CENTER, r: OO_RIG_EYE_SMALL_R }, } as const; const OO_RIG_FACE_PAD = { side: 12, above: 25, below: 30 } as const; /** Face crop for eyewear / eye-style pickers; keeps both sclerae in frame at every size. */ export const OO_RIG_FACE_VIEWBOX = (() => { const { big, small } = OO_RIG_EYES; const x = Math.floor(Math.min(big.cx - big.r, small.cx - small.r) - OO_RIG_FACE_PAD.side); const y = Math.floor(big.cy - big.r - OO_RIG_FACE_PAD.above); const right = Math.ceil(Math.max(big.cx + big.r, small.cx + small.r) + OO_RIG_FACE_PAD.side); const bottom = Math.ceil(Math.max(big.cy + big.r, small.cy + small.r) + OO_RIG_FACE_PAD.below); return `${x} ${y} ${right - x} ${bottom - y}`; })(); /** Minimum big÷small ratio for eyes, lenses and closed-eye arcs (Oo trademark). */ export const OO_EYE_ASYMMETRY_MIN_RATIO = 1.45; /** Lenses scale with the eye they sit on, so glasses keep the Oo asymmetry. */ export const OO_LENS_SCALE = 1.24; export function ooLensRadius(eye: keyof typeof OO_RIG_EYES): number { return OO_RIG_EYES[eye].r * OO_LENS_SCALE; }