mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(mascot): render dynamic backend Rive mascots with version cache (#4131)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user