feat(mascot): render dynamic backend Rive mascots with version cache (#4131)

This commit is contained in:
Steven Enamakel
2026-06-25 16:28:32 -07:00
committed by GitHub
parent dbd1c0cd6f
commit 0a4e91ca36
11 changed files with 653 additions and 18 deletions
@@ -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<MascotSummary[] | null>(null);
const [backendListError, setBackendListError] = useState<string | null>(null);
const [activeDetail, setActiveDetail] = useState<MascotDetail | null>(null);
const [activeDetail, setActiveDetail] = useState<MascotDetailUnion | null>(null);
const [detailError, setDetailError] = useState<string | null>(null);
const [customGifDraft, setCustomGifDraft] = useState<string>(customMascotGifUrl ?? '');
const [customGifError, setCustomGifError] = useState<string | null>(null);
@@ -672,9 +677,14 @@ const MascotPanel = ({ embedded = false }: MascotPanelProps) => {
<span className="flex flex-col">
<span>{summary.name}</span>
<span className="text-[10px] text-content-muted">
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')}`
: ''
}`}
</span>
</span>
{active && (
@@ -697,7 +707,15 @@ const MascotPanel = ({ embedded = false }: MascotPanelProps) => {
</p>
<div className="flex justify-center">
<div style={{ width: 160, height: 160 }}>
<BackendMascot mascot={visibleActiveDetail} />
{isRiveMascotDetail(visibleActiveDetail) ? (
<BackendRiveMascot
key={visibleActiveDetail.id}
mascotId={visibleActiveDetail.id}
size={160}
/>
) : (
<BackendMascot mascot={visibleActiveDetail} />
)}
</div>
</div>
</div>
+19 -1
View File
@@ -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 = () => {
<div className="relative w-[min(80vh,90%)] aspect-square">
{customMascotGifUrl ? (
<CustomGifMascot src={customMascotGifUrl} face={face} />
) : selectedMascotId ? (
<BackendRiveMascot
key={selectedMascotId}
mascotId={selectedMascotId}
face={face}
primaryColor={primaryColor}
secondaryColor={secondaryColor}
visemeCode={visemeCode}
idlePoseRotation
/>
) : (
<RiveMascot
face={face}
+20 -2
View File
@@ -33,6 +33,9 @@ function randBetween(min: number, max: number): number {
return min + Math.random() * (max - min);
}
/** Bundled default mascot, served by Vite from `public/`. */
export const DEFAULT_MASCOT_SRC = '/tiny_mascot.riv';
export interface RiveMascotProps {
face?: MascotFace;
size?: number | string;
@@ -46,6 +49,15 @@ export interface RiveMascotProps {
* ambient poses (thinking, sipping coffee, dancing, …) to feel alive.
* Off by default so small previews / frame producers stay still. */
idlePoseRotation?: boolean;
/** Override the bundled mascot with a URL to a different `.riv` file.
* Ignored when `buffer` is supplied. */
src?: string;
/** Render a `.riv` already loaded into memory (e.g. a version-cached custom
* mascot from the backend). Takes precedence over `src`. */
buffer?: ArrayBuffer;
/** State-machine name inside the `.riv`. Custom mascots are authored against
* the same `MascotSM` convention by default. */
stateMachine?: string;
}
const RIVE_LAYOUT = new Layout({ fit: Fit.Contain });
@@ -57,10 +69,16 @@ export const RiveMascot: FC<RiveMascotProps> = ({
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,
});
@@ -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<string, unknown>[] }));
vi.mock('@rive-app/react-webgl2', () => ({
Fit: { Contain: 'contain' },
Layout: class {
constructor(opts: unknown) {
Object.assign(this, opts as object);
}
},
useRive: (params: Record<string, unknown>) => {
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<string, unknown> {
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(<BackendRiveMascot mascotId="toshi" face="idle" />);
// 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(<BackendRiveMascot mascotId="toshi" />);
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(<BackendRiveMascot mascotId="old" />);
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(<BackendRiveMascot mascotId="toshi" size={160} />);
expect(container.querySelector('[data-face]')).not.toBeNull();
expect(screen).toBeDefined();
});
});
@@ -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<BackendRiveMascotProps> = ({ 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<ArrayBuffer | null>(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 <RiveMascot key="default" {...riveProps} />;
return <RiveMascot key={`buf-${mascotId}`} {...riveProps} buffer={buffer} />;
};
+40 -4
View File
@@ -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<MascotState, 'id' | 'label' | 'description'>[];
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<MascotState, 'id' | 'label' | 'description'>[];
hasVisemes?: boolean;
/** Rive-only: maps logical state ids to Rive pose enum values. */
stateToPose?: Record<string, string>;
}
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<string, string>;
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 };
}
+3 -1
View File
@@ -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';
@@ -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<string, Map<string, unknown>>(); // 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);
});
});
+137
View File
@@ -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<string, { version: string; buffer: ArrayBuffer }>();
function getIndexedDb(): IDBFactory | null {
try {
return typeof globalThis.indexedDB !== 'undefined' ? globalThis.indexedDB : null;
} catch {
return null;
}
}
function openDb(): Promise<IDBDatabase | null> {
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<RivCacheEntry | undefined> {
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<void> {
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<ArrayBuffer> {
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<ArrayBuffer> {
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();
}
+39
View File
@@ -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'
);
});
});
+19 -4
View File
@@ -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<MascotSummary[]> {
const res = await apiClient.get<ListMascotsResponse>('/mascots', { requireAuth: false });
return res.data.mascots;
}
export async function fetchMascotDetail(id: string): Promise<MascotDetail> {
export async function fetchMascotDetail(id: string): Promise<MascotDetailUnion> {
const safe = encodeURIComponent(id.trim());
if (!safe) throw new Error('mascot id is empty');
const res = await apiClient.get<GetMascotResponse>(`/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<ArrayBuffer> {
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<MascotDetail> {
* so a per-id memoization keeps the picker snappy without hammering the
* backend.
*/
const detailCache = new Map<string, MascotDetail>();
const detailCache = new Map<string, MascotDetailUnion>();
export async function getCachedMascotDetail(id: string): Promise<MascotDetail> {
export async function getCachedMascotDetail(id: string): Promise<MascotDetailUnion> {
const existing = detailCache.get(id);
if (existing) return existing;
const detail = await fetchMascotDetail(id);