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);