diff --git a/app/src/components/settings/panels/MascotPanel.tsx b/app/src/components/settings/panels/MascotPanel.tsx
index 17c55416b..dafa43d04 100644
--- a/app/src/components/settings/panels/MascotPanel.tsx
+++ b/app/src/components/settings/panels/MascotPanel.tsx
@@ -2,7 +2,12 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { CustomGifMascot, RiveMascot } from '../../../features/human/Mascot';
import { BackendMascot } from '../../../features/human/Mascot/backend/BackendMascot';
-import type { MascotDetail, MascotSummary } from '../../../features/human/Mascot/backend/types';
+import { BackendRiveMascot } from '../../../features/human/Mascot/backend/BackendRiveMascot';
+import {
+ isRiveMascotDetail,
+ type MascotDetailUnion,
+ type MascotSummary,
+} from '../../../features/human/Mascot/backend/types';
import {
getMascotPalette,
hexToArgbInt,
@@ -83,7 +88,7 @@ const MascotPanel = ({ embedded = false }: MascotPanelProps) => {
// animated preview only pays for the active selection.
const [backendList, setBackendList] = useState(null);
const [backendListError, setBackendListError] = useState(null);
- const [activeDetail, setActiveDetail] = useState(null);
+ const [activeDetail, setActiveDetail] = useState(null);
const [detailError, setDetailError] = useState(null);
const [customGifDraft, setCustomGifDraft] = useState(customMascotGifUrl ?? '');
const [customGifError, setCustomGifError] = useState(null);
@@ -672,9 +677,14 @@ const MascotPanel = ({ embedded = false }: MascotPanelProps) => {
{summary.name}
- v{summary.version} · {summary.states.length}{' '}
- {t('settings.mascot.characterStates')}
- {summary.hasVisemes ? ` · ${t('settings.mascot.characterVisemes')}` : ''}
+ v{summary.version}
+ {summary.format === 'rive'
+ ? ` · ${Object.keys(summary.stateToPose ?? {}).length} ${t('settings.mascot.characterStates')}`
+ : ` · ${summary.states?.length ?? 0} ${t('settings.mascot.characterStates')}${
+ summary.hasVisemes
+ ? ` · ${t('settings.mascot.characterVisemes')}`
+ : ''
+ }`}
{active && (
@@ -697,7 +707,15 @@ const MascotPanel = ({ embedded = false }: MascotPanelProps) => {
-
+ {isRiveMascotDetail(visibleActiveDetail) ? (
+
+ ) : (
+
+ )}
diff --git a/app/src/features/human/HumanPage.tsx b/app/src/features/human/HumanPage.tsx
index 4672fc443..858d742a7 100644
--- a/app/src/features/human/HumanPage.tsx
+++ b/app/src/features/human/HumanPage.tsx
@@ -8,8 +8,15 @@ import {
selectCustomPrimaryColor,
selectCustomSecondaryColor,
selectMascotColor,
+ selectSelectedMascotId,
} from '../../store/mascotSlice';
-import { CustomGifMascot, getMascotPalette, hexToArgbInt, RiveMascot } from './Mascot';
+import {
+ BackendRiveMascot,
+ CustomGifMascot,
+ getMascotPalette,
+ hexToArgbInt,
+ RiveMascot,
+} from './Mascot';
import { useHumanMascot } from './useHumanMascot';
const SPEAK_REPLIES_KEY = 'human.speakReplies';
@@ -30,6 +37,7 @@ const HumanPage = () => {
const customPrimary = useAppSelector(selectCustomPrimaryColor);
const customSecondary = useAppSelector(selectCustomSecondaryColor);
const customMascotGifUrl = useAppSelector(selectCustomMascotGifUrl);
+ const selectedMascotId = useAppSelector(selectSelectedMascotId);
const palette = getMascotPalette(mascotColor);
const primaryColor = useMemo(
() => hexToArgbInt(mascotColor === 'custom' ? customPrimary : palette.bodyFill),
@@ -54,6 +62,16 @@ const HumanPage = () => {
{customMascotGifUrl ? (
+ ) : selectedMascotId ? (
+
) : (
= ({
secondaryColor,
visemeCode = 'sil',
idlePoseRotation = false,
+ src = DEFAULT_MASCOT_SRC,
+ buffer,
+ stateMachine = MASCOT_STATE_MACHINE,
}) => {
+ // `buffer` (an in-memory custom mascot) wins over `src` (a URL). Passing only
+ // one of the two to useRive keeps the runtime from racing both loaders.
+ const riveSource = buffer ? { buffer } : { src };
const { rive, RiveComponent } = useRive({
- src: '/tiny_mascot.riv',
- stateMachines: MASCOT_STATE_MACHINE,
+ ...riveSource,
+ stateMachines: stateMachine,
autoplay: true,
layout: RIVE_LAYOUT,
});
diff --git a/app/src/features/human/Mascot/backend/BackendRiveMascot.test.tsx b/app/src/features/human/Mascot/backend/BackendRiveMascot.test.tsx
new file mode 100644
index 000000000..944a0e8aa
--- /dev/null
+++ b/app/src/features/human/Mascot/backend/BackendRiveMascot.test.tsx
@@ -0,0 +1,100 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { getCachedMascotDetail, loadMascotRivBuffer } from '../../../../services/mascotService';
+import { BackendRiveMascot } from './BackendRiveMascot';
+import type { MascotDetail, RiveMascotDetail } from './types';
+
+// Capture the props useRive receives so we can assert which source (default
+// src vs. custom buffer) the mascot is rendered from.
+const h = vi.hoisted(() => ({ useRiveParams: [] as Record[] }));
+
+vi.mock('@rive-app/react-webgl2', () => ({
+ Fit: { Contain: 'contain' },
+ Layout: class {
+ constructor(opts: unknown) {
+ Object.assign(this, opts as object);
+ }
+ },
+ useRive: (params: Record) => {
+ h.useRiveParams.push(params);
+ return { rive: {}, RiveComponent: () => null };
+ },
+ useViewModel: () => ({}),
+ useViewModelInstance: () => ({}),
+ useViewModelInstanceEnum: () => ({ setValue: () => {}, value: null, values: [] }),
+ useViewModelInstanceColor: () => ({ setValue: () => {} }),
+}));
+
+vi.mock('../../../../services/mascotService', () => ({
+ getCachedMascotDetail: vi.fn(),
+ loadMascotRivBuffer: vi.fn(),
+}));
+
+const riveDetail: RiveMascotDetail = {
+ id: 'toshi',
+ name: 'Toshi',
+ version: '1.0.0',
+ description: '',
+ format: 'rive',
+ rivFileUrl: '/mascots/toshi/riv?v=1.0.0',
+ defaultState: 'idle',
+ stateToPose: {},
+ viewModelInputs: [],
+};
+
+function lastSource(): Record {
+ return h.useRiveParams.at(-1) ?? {};
+}
+
+describe('BackendRiveMascot', () => {
+ beforeEach(() => {
+ h.useRiveParams = [];
+ vi.clearAllMocks();
+ });
+
+ it('renders the version-cached buffer once the mascot resolves', async () => {
+ const buffer = new ArrayBuffer(8);
+ vi.mocked(getCachedMascotDetail).mockResolvedValue(riveDetail);
+ vi.mocked(loadMascotRivBuffer).mockResolvedValue(buffer);
+
+ render();
+
+ // Starts on the bundled default while loading...
+ expect(lastSource().src).toBe('/tiny_mascot.riv');
+
+ // ...then swaps to the custom buffer.
+ await waitFor(() => expect(lastSource().buffer).toBe(buffer));
+ expect(loadMascotRivBuffer).toHaveBeenCalledWith(riveDetail);
+ });
+
+ it('falls back to the default mascot when the load fails', async () => {
+ vi.mocked(getCachedMascotDetail).mockRejectedValue(new Error('network'));
+
+ render();
+
+ await waitFor(() => expect(getCachedMascotDetail).toHaveBeenCalled());
+ // Never rendered a buffer — stayed on the bundled default.
+ expect(h.useRiveParams.every(p => p.buffer === undefined)).toBe(true);
+ expect(lastSource().src).toBe('/tiny_mascot.riv');
+ });
+
+ it('falls back to the default when the selected mascot is an SVG (non-rive)', async () => {
+ const svgDetail = { id: 'old', format: 'svg', states: [] } as unknown as MascotDetail;
+ vi.mocked(getCachedMascotDetail).mockResolvedValue(svgDetail);
+
+ render();
+
+ await waitFor(() => expect(getCachedMascotDetail).toHaveBeenCalled());
+ expect(loadMascotRivBuffer).not.toHaveBeenCalled();
+ expect(lastSource().src).toBe('/tiny_mascot.riv');
+ });
+
+ it('renders a canvas container', () => {
+ vi.mocked(getCachedMascotDetail).mockResolvedValue(riveDetail);
+ vi.mocked(loadMascotRivBuffer).mockResolvedValue(new ArrayBuffer(8));
+ const { container } = render();
+ expect(container.querySelector('[data-face]')).not.toBeNull();
+ expect(screen).toBeDefined();
+ });
+});
diff --git a/app/src/features/human/Mascot/backend/BackendRiveMascot.tsx b/app/src/features/human/Mascot/backend/BackendRiveMascot.tsx
new file mode 100644
index 000000000..c977b4144
--- /dev/null
+++ b/app/src/features/human/Mascot/backend/BackendRiveMascot.tsx
@@ -0,0 +1,67 @@
+// Renders a backend-served Rive mascot selected by id.
+//
+// Fetches the manifest (cached), then resolves the .riv binary through the
+// version-keyed cache (`rivCache` → IndexedDB) so the backend is only hit when
+// the mascot's version changes. While the binary loads — or if it fails — we
+// fall back to the bundled default mascot so the Human stage is never blank.
+import debug from 'debug';
+import { type FC, useEffect, useState } from 'react';
+
+import { getCachedMascotDetail, loadMascotRivBuffer } from '../../../../services/mascotService';
+import type { MascotFace } from '../Ghosty';
+import { RiveMascot } from '../RiveMascot';
+import { isRiveMascotDetail } from './types';
+
+const log = debug('human:mascot:backend-rive');
+
+export interface BackendRiveMascotProps {
+ mascotId: string;
+ face?: MascotFace;
+ size?: number | string;
+ primaryColor?: number;
+ secondaryColor?: number;
+ visemeCode?: string;
+ idlePoseRotation?: boolean;
+}
+
+export const BackendRiveMascot: FC = ({ mascotId, ...riveProps }) => {
+ // Callers key this component by mascotId so a new selection remounts it with
+ // fresh state — meaning the effect never has to synchronously reset here, it
+ // only resolves the buffer (or marks failure) asynchronously.
+ const [buffer, setBuffer] = useState(null);
+ const [failed, setFailed] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ (async () => {
+ try {
+ const detail = await getCachedMascotDetail(mascotId);
+ if (cancelled) return;
+ if (!isRiveMascotDetail(detail)) {
+ // Selected id is an SVG mascot — not renderable here. Fall back.
+ log('mascot %s is not a rive mascot; using default', mascotId);
+ setFailed(true);
+ return;
+ }
+ const buf = await loadMascotRivBuffer(detail);
+ if (!cancelled) setBuffer(buf);
+ } catch (err) {
+ if (!cancelled) {
+ log('failed to load backend rive mascot %s: %o', mascotId, err);
+ setFailed(true);
+ }
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [mascotId]);
+
+ // Default mascot while loading or on failure; the custom buffer once ready.
+ // Key the instance so the Rive runtime cleanly reinitialises when the buffer
+ // arrives (or when switching mascots) rather than mutating a live context.
+ if (failed || !buffer) return ;
+ return ;
+};
diff --git a/app/src/features/human/Mascot/backend/types.ts b/app/src/features/human/Mascot/backend/types.ts
index a37e43f2c..e858e9d33 100644
--- a/app/src/features/human/Mascot/backend/types.ts
+++ b/app/src/features/human/Mascot/backend/types.ts
@@ -61,14 +61,20 @@ export interface MascotViseme {
svg: string;
}
+export type MascotFormat = 'svg' | 'rive';
+
export interface MascotSummary {
id: string;
name: string;
version: string;
description: string;
- /** State metadata only — no SVG bytes or tween, to keep list payload light. */
- states: Pick[];
- hasVisemes: boolean;
+ /** 'svg' (legacy) or 'rive' (current). Absent on old backends => treat as svg. */
+ format?: MascotFormat;
+ /** SVG-only: state metadata (no SVG bytes or tween) to keep list payload light. */
+ states?: Pick[];
+ hasVisemes?: boolean;
+ /** Rive-only: maps logical state ids to Rive pose enum values. */
+ stateToPose?: Record;
}
export interface MascotDetail {
@@ -76,6 +82,7 @@ export interface MascotDetail {
name: string;
version: string;
description: string;
+ format?: 'svg';
viewBox: string;
defaultState: string;
variables: MascotVariable[];
@@ -85,6 +92,35 @@ export interface MascotDetail {
hidesOnViseme?: string[];
}
+export type ViewModelInputType = 'number' | 'boolean' | 'color' | 'string' | 'enum';
+
+export interface ViewModelInput {
+ name: string;
+ type: ViewModelInputType;
+ description?: string;
+}
+
+/** Rive mascot manifest — the binary lives at `rivFileUrl` (version-stamped). */
+export interface RiveMascotDetail {
+ id: string;
+ name: string;
+ version: string;
+ description: string;
+ format: 'rive';
+ /** Backend-relative URL of the .riv binary, e.g. `/mascots/toshi/riv?v=1.0.0`. */
+ rivFileUrl: string;
+ source?: 'builtin' | 'custom';
+ defaultState: string;
+ stateToPose: Record;
+ viewModelInputs: ViewModelInput[];
+}
+
+export type MascotDetailUnion = MascotDetail | RiveMascotDetail;
+
+export function isRiveMascotDetail(d: MascotDetailUnion): d is RiveMascotDetail {
+ return d.format === 'rive';
+}
+
/** Wire shapes for /mascots and /mascots/:id. */
export interface ListMascotsResponse {
success: true;
@@ -93,5 +129,5 @@ export interface ListMascotsResponse {
export interface GetMascotResponse {
success: true;
- data: { mascot: MascotDetail };
+ data: { mascot: MascotDetailUnion };
}
diff --git a/app/src/features/human/Mascot/index.ts b/app/src/features/human/Mascot/index.ts
index f1aba9c7c..11dba456f 100644
--- a/app/src/features/human/Mascot/index.ts
+++ b/app/src/features/human/Mascot/index.ts
@@ -4,8 +4,10 @@ export { CustomGifMascot } from './CustomGifMascot';
export type { CustomGifMascotProps } from './CustomGifMascot';
export { MascotChipAvatar } from './MascotChipAvatar';
export type { MascotChipAvatarProps } from './MascotChipAvatar';
-export { RiveMascot } from './RiveMascot';
+export { RiveMascot, DEFAULT_MASCOT_SRC } from './RiveMascot';
export type { RiveMascotProps } from './RiveMascot';
+export { BackendRiveMascot } from './backend/BackendRiveMascot';
+export type { BackendRiveMascotProps } from './backend/BackendRiveMascot';
export { lerpViseme, VISEMES, visemePath } from './visemes';
export type { VisemeId, VisemeShape } from './visemes';
export { getMascotPalette, hexToArgbInt } from './mascotPalette';
diff --git a/app/src/features/human/Mascot/rivCache.test.ts b/app/src/features/human/Mascot/rivCache.test.ts
new file mode 100644
index 000000000..638d01ff1
--- /dev/null
+++ b/app/src/features/human/Mascot/rivCache.test.ts
@@ -0,0 +1,185 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { clearRivMemoryCache, loadRivBuffer } from './rivCache';
+
+function riv(byte = 1): ArrayBuffer {
+ return new Uint8Array([0x52, 0x49, 0x56, 0x45, byte]).buffer; // "RIVE" + tag
+}
+
+describe('rivCache.loadRivBuffer (version-keyed)', () => {
+ beforeEach(() => {
+ clearRivMemoryCache();
+ });
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ function mockFetch(buffer: ArrayBuffer) {
+ const fn = vi
+ .fn()
+ .mockResolvedValue({
+ ok: true,
+ arrayBuffer: () => Promise.resolve(buffer),
+ } as unknown as Response);
+ vi.stubGlobal('fetch', fn);
+ return fn;
+ }
+
+ it('fetches once, then serves the same version from cache', async () => {
+ const fetchFn = mockFetch(riv(1));
+
+ const a = await loadRivBuffer('toshi', '1.0.0', 'http://x/mascots/toshi/riv?v=1.0.0');
+ const b = await loadRivBuffer('toshi', '1.0.0', 'http://x/mascots/toshi/riv?v=1.0.0');
+
+ expect(new Uint8Array(a)[0]).toBe(0x52);
+ expect(b).toBe(a); // identical cached buffer
+ expect(fetchFn).toHaveBeenCalledTimes(1); // no second network hit
+ });
+
+ it('re-fetches when the version changes', async () => {
+ const fetchFn = mockFetch(riv(1));
+ await loadRivBuffer('toshi', '1.0.0', 'http://x/riv?v=1.0.0');
+ expect(fetchFn).toHaveBeenCalledTimes(1);
+
+ await loadRivBuffer('toshi', '2.0.0', 'http://x/riv?v=2.0.0');
+ expect(fetchFn).toHaveBeenCalledTimes(2); // version bump invalidates cache
+ });
+
+ it('throws on a non-OK response', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({ ok: false, status: 404 } as unknown as Response)
+ );
+ await expect(loadRivBuffer('missing', '1', 'http://x/riv')).rejects.toThrow(/404/);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// IndexedDB persistence — exercised with a minimal in-memory fake so the
+// open/read/write/close code paths run under jsdom (which has no IndexedDB).
+// ---------------------------------------------------------------------------
+
+interface FakeReq {
+ result: unknown;
+ error: unknown;
+ onsuccess: ((ev: unknown) => void) | null;
+ onerror: ((ev: unknown) => void) | null;
+ onupgradeneeded?: ((ev: unknown) => void) | null;
+}
+
+function makeFakeIndexedDb() {
+ const backing = new Map>(); // store -> (key -> value)
+
+ function makeStore(name: string) {
+ const map = backing.get(name) ?? new Map();
+ backing.set(name, map);
+ return {
+ get(key: string) {
+ const req: FakeReq = { result: undefined, error: null, onsuccess: null, onerror: null };
+ setTimeout(() => {
+ req.result = map.get(key);
+ req.onsuccess?.({ target: req });
+ }, 0);
+ return req;
+ },
+ put(value: { id: string }) {
+ map.set(value.id, value);
+ return { onsuccess: null, onerror: null };
+ },
+ };
+ }
+
+ function makeDb() {
+ return {
+ objectStoreNames: { contains: (n: string) => backing.has(n) },
+ createObjectStore: (n: string) => {
+ backing.set(n, backing.get(n) ?? new Map());
+ },
+ transaction() {
+ const tx: {
+ oncomplete: (() => void) | null;
+ onerror: null;
+ onabort: null;
+ objectStore: (n: string) => unknown;
+ } = {
+ oncomplete: null,
+ onerror: null,
+ onabort: null,
+ objectStore: (n: string) => makeStore(n),
+ };
+ // Resolve writes after the current microtask so put() has run.
+ setTimeout(() => tx.oncomplete?.(), 0);
+ return tx;
+ },
+ close() {},
+ };
+ }
+
+ return {
+ open() {
+ const req: FakeReq = {
+ result: undefined,
+ error: null,
+ onsuccess: null,
+ onerror: null,
+ onupgradeneeded: null,
+ };
+ setTimeout(() => {
+ req.result = makeDb();
+ req.onupgradeneeded?.({ target: req });
+ req.onsuccess?.({ target: req });
+ }, 0);
+ return req;
+ },
+ };
+}
+
+describe('rivCache IndexedDB persistence', () => {
+ beforeEach(() => {
+ clearRivMemoryCache();
+ vi.stubGlobal('indexedDB', makeFakeIndexedDb());
+ });
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it('persists to IndexedDB and serves a later mount from it (no refetch)', async () => {
+ const fetchFn = vi
+ .fn()
+ .mockResolvedValue({
+ ok: true,
+ arrayBuffer: () => Promise.resolve(riv(7)),
+ } as unknown as Response);
+ vi.stubGlobal('fetch', fetchFn);
+
+ // First load: miss → fetch → write to IDB.
+ await loadRivBuffer('toshi', '1.0.0', 'http://x/riv?v=1.0.0');
+ expect(fetchFn).toHaveBeenCalledTimes(1);
+
+ // Drop the in-memory layer so the next read must come from IndexedDB.
+ clearRivMemoryCache();
+ const again = await loadRivBuffer('toshi', '1.0.0', 'http://x/riv?v=1.0.0');
+ expect(new Uint8Array(again)[0]).toBe(0x52);
+ expect(fetchFn).toHaveBeenCalledTimes(1); // served from IDB, no second fetch
+ });
+
+ it('ignores a stale IndexedDB entry when the version changed', async () => {
+ const fetchFn = vi
+ .fn()
+ .mockResolvedValueOnce({
+ ok: true,
+ arrayBuffer: () => Promise.resolve(riv(1)),
+ } as unknown as Response)
+ .mockResolvedValueOnce({
+ ok: true,
+ arrayBuffer: () => Promise.resolve(riv(2)),
+ } as unknown as Response);
+ vi.stubGlobal('fetch', fetchFn);
+
+ await loadRivBuffer('toshi', '1.0.0', 'http://x/riv?v=1.0.0');
+ clearRivMemoryCache();
+ await loadRivBuffer('toshi', '2.0.0', 'http://x/riv?v=2.0.0');
+ expect(fetchFn).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/app/src/features/human/Mascot/rivCache.ts b/app/src/features/human/Mascot/rivCache.ts
new file mode 100644
index 000000000..38f246e80
--- /dev/null
+++ b/app/src/features/human/Mascot/rivCache.ts
@@ -0,0 +1,137 @@
+// Version-keyed cache for backend-served Rive (.riv) binaries.
+//
+// The backend stamps each mascot with a `version`. We persist the downloaded
+// binary in IndexedDB keyed by mascot id and only re-fetch from the backend
+// when the stored version differs from the requested one — so switching back
+// to a previously-seen mascot, or remounting the Human page, never re-hits the
+// network. A small in-memory layer sits in front so repeated mounts in the
+// same session skip even the IndexedDB round-trip.
+//
+// IndexedDB is best-effort: if it is unavailable (e.g. jsdom under Vitest, or a
+// hardened webview) every helper degrades to a direct fetch with no caching.
+import debug from 'debug';
+
+const cacheLog = debug('human:mascot:riv-cache');
+
+const DB_NAME = 'openhuman-mascots';
+const STORE = 'riv';
+const DB_VERSION = 1;
+
+interface RivCacheEntry {
+ id: string;
+ version: string;
+ buffer: ArrayBuffer;
+ updatedAt: number;
+}
+
+/** Session-lifetime memory cache: id → { version, buffer }. */
+const memCache = new Map();
+
+function getIndexedDb(): IDBFactory | null {
+ try {
+ return typeof globalThis.indexedDB !== 'undefined' ? globalThis.indexedDB : null;
+ } catch {
+ return null;
+ }
+}
+
+function openDb(): Promise {
+ const idb = getIndexedDb();
+ if (!idb) return Promise.resolve(null);
+ return new Promise(resolve => {
+ let req: IDBOpenDBRequest;
+ try {
+ req = idb.open(DB_NAME, DB_VERSION);
+ } catch (err) {
+ cacheLog('indexedDB.open threw, skipping cache: %o', err);
+ resolve(null);
+ return;
+ }
+ req.onupgradeneeded = () => {
+ const db = req.result;
+ if (!db.objectStoreNames.contains(STORE)) {
+ db.createObjectStore(STORE, { keyPath: 'id' });
+ }
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => {
+ cacheLog('indexedDB.open error: %o', req.error);
+ resolve(null);
+ };
+ });
+}
+
+function readEntry(db: IDBDatabase, id: string): Promise {
+ return new Promise(resolve => {
+ try {
+ const tx = db.transaction(STORE, 'readonly');
+ const req = tx.objectStore(STORE).get(id);
+ req.onsuccess = () => resolve(req.result as RivCacheEntry | undefined);
+ req.onerror = () => resolve(undefined);
+ } catch {
+ resolve(undefined);
+ }
+ });
+}
+
+function writeEntry(db: IDBDatabase, entry: RivCacheEntry): Promise {
+ return new Promise(resolve => {
+ try {
+ const tx = db.transaction(STORE, 'readwrite');
+ tx.objectStore(STORE).put(entry);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => resolve();
+ tx.onabort = () => resolve();
+ } catch {
+ resolve();
+ }
+ });
+}
+
+async function fetchBuffer(url: string): Promise {
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`failed to fetch riv (${res.status}) from ${url}`);
+ return res.arrayBuffer();
+}
+
+/**
+ * Resolve the .riv binary for a mascot, hitting the network only when the
+ * cached version differs from `version`. Returns an ArrayBuffer suitable for
+ * `useRive({ buffer })`.
+ */
+export async function loadRivBuffer(
+ id: string,
+ version: string,
+ url: string
+): Promise {
+ const mem = memCache.get(id);
+ if (mem && mem.version === version) {
+ cacheLog('mem hit %s@%s', id, version);
+ return mem.buffer;
+ }
+
+ const db = await openDb();
+ if (db) {
+ const entry = await readEntry(db, id);
+ if (entry && entry.version === version) {
+ cacheLog('idb hit %s@%s', id, version);
+ memCache.set(id, { version, buffer: entry.buffer });
+ db.close();
+ return entry.buffer;
+ }
+ }
+
+ cacheLog('miss %s@%s → fetching %s', id, version, url);
+ const buffer = await fetchBuffer(url);
+ memCache.set(id, { version, buffer });
+ if (db) {
+ await writeEntry(db, { id, version, buffer, updatedAt: Date.now() });
+ db.close();
+ }
+ return buffer;
+}
+
+/** Test/maintenance helper — drops the in-memory layer. */
+export function clearRivMemoryCache(): void {
+ memCache.clear();
+}
diff --git a/app/src/services/mascotService.test.ts b/app/src/services/mascotService.test.ts
new file mode 100644
index 000000000..58913ba22
--- /dev/null
+++ b/app/src/services/mascotService.test.ts
@@ -0,0 +1,39 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { RiveMascotDetail } from '../features/human/Mascot/backend/types';
+import { loadRivBuffer } from '../features/human/Mascot/rivCache';
+import { getBackendUrl } from './backendUrl';
+import { loadMascotRivBuffer } from './mascotService';
+
+vi.mock('../features/human/Mascot/rivCache', () => ({ loadRivBuffer: vi.fn() }));
+vi.mock('./backendUrl', () => ({ getBackendUrl: vi.fn() }));
+vi.mock('./apiClient', () => ({ apiClient: { get: vi.fn() } }));
+
+const rive: RiveMascotDetail = {
+ id: 'toshi',
+ name: 'Toshi',
+ version: '1.0.0',
+ description: '',
+ format: 'rive',
+ rivFileUrl: '/mascots/toshi/riv?v=1.0.0',
+ defaultState: 'idle',
+ stateToPose: {},
+ viewModelInputs: [],
+};
+
+describe('loadMascotRivBuffer', () => {
+ beforeEach(() => {
+ vi.mocked(getBackendUrl).mockResolvedValue('https://api.example.test');
+ vi.mocked(loadRivBuffer).mockResolvedValue(new ArrayBuffer(8));
+ });
+
+ it('composes the absolute, version-stamped URL and delegates to the cache', async () => {
+ const buf = await loadMascotRivBuffer(rive);
+ expect(buf).toBeInstanceOf(ArrayBuffer);
+ expect(loadRivBuffer).toHaveBeenCalledWith(
+ 'toshi',
+ '1.0.0',
+ 'https://api.example.test/mascots/toshi/riv?v=1.0.0'
+ );
+ });
+});
diff --git a/app/src/services/mascotService.ts b/app/src/services/mascotService.ts
index 2951a23c1..184d106a3 100644
--- a/app/src/services/mascotService.ts
+++ b/app/src/services/mascotService.ts
@@ -6,23 +6,38 @@
import type {
GetMascotResponse,
ListMascotsResponse,
- MascotDetail,
+ MascotDetailUnion,
MascotSummary,
+ RiveMascotDetail,
} from '../features/human/Mascot/backend/types';
+import { loadRivBuffer } from '../features/human/Mascot/rivCache';
import { apiClient } from './apiClient';
+import { getBackendUrl } from './backendUrl';
export async function fetchMascotList(): Promise {
const res = await apiClient.get('/mascots', { requireAuth: false });
return res.data.mascots;
}
-export async function fetchMascotDetail(id: string): Promise {
+export async function fetchMascotDetail(id: string): Promise {
const safe = encodeURIComponent(id.trim());
if (!safe) throw new Error('mascot id is empty');
const res = await apiClient.get(`/mascots/${safe}`, { requireAuth: false });
return res.data.mascot;
}
+/**
+ * Resolve a Rive mascot's binary, version-cached in IndexedDB. The backend
+ * stamps `version` into both the manifest and the `rivFileUrl` (`?v=`), so the
+ * binary is only re-downloaded when that version changes.
+ */
+export async function loadMascotRivBuffer(detail: RiveMascotDetail): Promise {
+ const base = await getBackendUrl();
+ // rivFileUrl is backend-relative (e.g. "/mascots/toshi/riv?v=1.0.0").
+ const url = `${base}${detail.rivFileUrl}`;
+ return loadRivBuffer(detail.id, detail.version, url);
+}
+
/**
* Lightweight in-memory cache for manifest fetches. Manifests carry the
* full SVG bytes for every state (~ tens of KB per mascot) — the WebRTC
@@ -30,9 +45,9 @@ export async function fetchMascotDetail(id: string): Promise {
* so a per-id memoization keeps the picker snappy without hammering the
* backend.
*/
-const detailCache = new Map();
+const detailCache = new Map();
-export async function getCachedMascotDetail(id: string): Promise {
+export async function getCachedMascotDetail(id: string): Promise {
const existing = detailCache.get(id);
if (existing) return existing;
const detail = await fetchMascotDetail(id);