/** * Duck customization profiles: seven colors and a name, packed into a * compact base64url string * ([version=1][slot count=7][7 x rgb][name utf-8, <=24 bytes]). * The encoded form lives in localStorage and, for signed-in users, in * their private Hugging Face storage — never in URLs. */ export type DuckProfile = { name: string; /** sRGB 0..255 per channel; one entry per SLOT_LABELS item. */ colors: [number, number, number][]; }; const VERSION = 1; const SLOT_COUNT = 7; const HEADER_BYTES = 2; const NAME_MAX_BYTES = 24; const STORAGE_KEY = "mq-duck"; export const SLOT_LABELS = ["Head", "Shell", "Face", "Beak", "Eye", "Feet", "Soles"]; function toBase64Url(bytes: Uint8Array): string { let bin = ""; for (const b of bytes) bin += String.fromCharCode(b); return btoa(bin).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); } function fromBase64Url(text: string): Uint8Array | null { try { const bin = atob(text.replaceAll("-", "+").replaceAll("_", "/")); return Uint8Array.from(bin, (c) => c.charCodeAt(0)); } catch { return null; } } export function encodeProfile(profile: DuckProfile): string { const name = new TextEncoder().encode(profile.name).slice(0, NAME_MAX_BYTES); const bytes = new Uint8Array(HEADER_BYTES + SLOT_COUNT * 3 + name.length); bytes[0] = VERSION; bytes[1] = SLOT_COUNT; for (let i = 0; i < SLOT_COUNT; i++) { const c = profile.colors[i] ?? [255, 255, 255]; bytes[HEADER_BYTES + i * 3] = c[0]; bytes[HEADER_BYTES + i * 3 + 1] = c[1]; bytes[HEADER_BYTES + i * 3 + 2] = c[2]; } bytes.set(name, HEADER_BYTES + SLOT_COUNT * 3); return toBase64Url(bytes); } function isCurrentProfile(bytes: Uint8Array | null): bytes is Uint8Array { return ( bytes !== null && bytes.length >= HEADER_BYTES + SLOT_COUNT * 3 && bytes[0] === VERSION && bytes[1] === SLOT_COUNT ); } export function decodeProfile(encoded: string): DuckProfile | null { const bytes = fromBase64Url(encoded); if (!isCurrentProfile(bytes)) return null; const colors: [number, number, number][] = []; for (let i = 0; i < SLOT_COUNT; i++) { const offset = HEADER_BYTES + i * 3; colors.push([bytes[offset] ?? 0, bytes[offset + 1] ?? 0, bytes[offset + 2] ?? 0]); } const name = new TextDecoder().decode(bytes.slice(HEADER_BYTES + SLOT_COUNT * 3)); return { name, colors }; } export function profileFromStorage(): DuckProfile | null { try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? decodeProfile(raw) : null; } catch { return null; } } /** Persist locally; the duck lives in this browser (or the HF account). */ export function saveProfile(profile: DuckProfile): void { try { localStorage.setItem(STORAGE_KEY, encodeProfile(profile)); } catch { // private mode: the duck lasts for this visit only } } // ── color space: pickers speak sRGB hex, materials speak linear ───────── export function srgbToLinear(c: [number, number, number]): [number, number, number] { return [(c[0] / 255) ** 2.2, (c[1] / 255) ** 2.2, (c[2] / 255) ** 2.2]; } export function linearToSrgb(c: [number, number, number]): [number, number, number] { const conv = (v: number): number => Math.round(255 * Math.min(1, Math.max(0, v)) ** (1 / 2.2)); return [conv(c[0]), conv(c[1]), conv(c[2])]; } export function srgbToHex(c: [number, number, number]): string { return `#${c.map((v) => v.toString(16).padStart(2, "0")).join("")}`; } export function hexToSrgb(hex: string): [number, number, number] { const n = parseInt(hex.replace("#", ""), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }