diff --git a/app/package.json b/app/package.json
index a19b042ca6..0af6ef9356 100644
--- a/app/package.json
+++ b/app/package.json
@@ -75,13 +75,13 @@
"@reduxjs/toolkit": "^2.11.2",
"@remotion/player": "4.0.454",
"@remotion/zod-types": "4.0.454",
+ "@rive-app/react-webgl2": "^4.28.6",
"@scure/base": "^2.2.0",
"@scure/bip32": "^2.0.1",
"@scure/bip39": "^2.0.1",
"@sentry/react": "^10.38.0",
"@tauri-apps/api": "^2.10.0",
"@tauri-apps/plugin-barcode-scanner": "^2.4.4",
- "tauri-plugin-ptt-api": "workspace:*",
"@tauri-apps/plugin-deep-link": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "^2.3.2",
@@ -104,6 +104,7 @@
"redux-persist": "^6.0.0",
"remotion": "4.0.454",
"socket.io-client": "^4.8.3",
+ "tauri-plugin-ptt-api": "workspace:*",
"three": "^0.183.2",
"util": "^0.12.5",
"zod": "4.3.6"
diff --git a/app/public/tiny_mascot.riv b/app/public/tiny_mascot.riv
new file mode 100644
index 0000000000..5e259e66fc
Binary files /dev/null and b/app/public/tiny_mascot.riv differ
diff --git a/app/src/components/settings/panels/MascotPanel.tsx b/app/src/components/settings/panels/MascotPanel.tsx
index bb0776a6f9..0184c1a0c3 100644
--- a/app/src/components/settings/panels/MascotPanel.tsx
+++ b/app/src/components/settings/panels/MascotPanel.tsx
@@ -1,9 +1,13 @@
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
-import { CustomGifMascot } from '../../../features/human/Mascot';
+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 { getMascotPalette, type MascotColor } from '../../../features/human/Mascot/mascotPalette';
+import {
+ getMascotPalette,
+ hexToArgbInt,
+ type MascotColor,
+} from '../../../features/human/Mascot/mascotPalette';
import { synthesizeSpeech } from '../../../features/human/voice/ttsClient';
import { useT } from '../../../lib/i18n/I18nContext';
import { fetchMascotList, getCachedMascotDetail } from '../../../services/mascotService';
@@ -13,6 +17,8 @@ import {
isCustomMascotGifUrl,
type MascotVoiceGender,
selectCustomMascotGifUrl,
+ selectCustomPrimaryColor,
+ selectCustomSecondaryColor,
selectEffectiveMascotVoiceId,
selectMascotColor,
selectMascotVoiceGender,
@@ -20,6 +26,8 @@ import {
selectMascotVoiceUseLocaleDefault,
selectSelectedMascotId,
setCustomMascotGifUrl,
+ setCustomPrimaryColor,
+ setCustomSecondaryColor,
setMascotColor,
setMascotVoiceGender,
setMascotVoiceId,
@@ -47,7 +55,7 @@ const COLOR_OPTIONS: ColorOption[] = [
{ id: 'burgundy', labelKey: 'settings.mascot.colorBurgundy' },
{ id: 'black', labelKey: 'settings.mascot.colorBlack' },
{ id: 'navy', labelKey: 'settings.mascot.colorNavy' },
- { id: 'green', labelKey: 'settings.mascot.colorGreen' },
+ { id: 'custom', labelKey: 'settings.mascot.colorCustom' },
];
const MascotPanel = () => {
@@ -55,6 +63,8 @@ const MascotPanel = () => {
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const dispatch = useAppDispatch();
const storedColor = useAppSelector(selectMascotColor);
+ const customPrimary = useAppSelector(selectCustomPrimaryColor);
+ const customSecondary = useAppSelector(selectCustomSecondaryColor);
const selectedMascotId = useAppSelector(selectSelectedMascotId);
const customMascotGifUrl = useAppSelector(selectCustomMascotGifUrl);
const storedVoiceId = useAppSelector(selectMascotVoiceId);
@@ -280,6 +290,16 @@ const MascotPanel = () => {
const visibleActiveDetail = selectedMascotId ? activeDetail : null;
const visibleDetailError = selectedMascotId ? detailError : null;
+ const activePalette = getMascotPalette(activeColor);
+ const primaryColorArgb = useMemo(
+ () => hexToArgbInt(activeColor === 'custom' ? customPrimary : activePalette.bodyFill),
+ [activeColor, customPrimary, activePalette]
+ );
+ const secondaryColorArgb = useMemo(
+ () => hexToArgbInt(activeColor === 'custom' ? customSecondary : activePalette.neckShadowColor),
+ [activeColor, customSecondary, activePalette]
+ );
+
return (
{
/>
+
+
{t('settings.mascot.colorHeading')}
@@ -328,7 +359,13 @@ const MascotPanel = () => {
? 'border-primary-500 shadow-soft'
: 'border-stone-200 dark:border-neutral-800'
}`}
- style={{ backgroundColor: palette.bodyFill }}
+ style={
+ opt.id === 'custom'
+ ? {
+ background: `linear-gradient(135deg, ${customPrimary} 50%, ${customSecondary} 50%)`,
+ }
+ : { backgroundColor: palette.bodyFill }
+ }
/>
{label}
@@ -337,6 +374,38 @@ const MascotPanel = () => {
)}
+ {activeColor === 'custom' && (
+
+
+
+
+ )}
{t('settings.mascot.colorDesc')}
diff --git a/app/src/components/settings/panels/__tests__/MascotPanel.test.tsx b/app/src/components/settings/panels/__tests__/MascotPanel.test.tsx
index 6f126aab76..0dec59aa46 100644
--- a/app/src/components/settings/panels/__tests__/MascotPanel.test.tsx
+++ b/app/src/components/settings/panels/__tests__/MascotPanel.test.tsx
@@ -24,6 +24,17 @@ vi.mock('../../../../services/mascotService', () => ({
getCachedMascotDetail: (...args: unknown[]) => getCachedMascotDetailMock(...args),
}));
+vi.mock('../../../../features/human/Mascot', async importOriginal => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ RiveMascot: () => ,
+ CustomGifMascot: ({ src }: { src: string }) => (
+
+ ),
+ };
+});
+
vi.mock('../../../../features/human/Mascot/backend/BackendMascot', () => ({
BackendMascot: ({ mascot }: { mascot: { id: string } }) => (
@@ -64,7 +75,7 @@ describe('MascotPanel', () => {
it('renders a radio swatch for each supported color', () => {
renderPanel();
expect(screen.getByRole('radiogroup', { name: 'OpenHuman color' })).toBeInTheDocument();
- for (const label of ['Yellow', 'Burgundy', 'Black', 'Navy', 'Green']) {
+ for (const label of ['Yellow', 'Burgundy', 'Black', 'Navy', 'Custom']) {
expect(screen.getByRole('radio', { name: label })).toBeInTheDocument();
}
});
@@ -85,13 +96,13 @@ describe('MascotPanel', () => {
it('is a no-op when clicking the already-selected color', () => {
const store = buildStore();
- store.dispatch(setMascotColor('green'));
+ store.dispatch(setMascotColor('custom'));
const dispatchSpy = vi.spyOn(store, 'dispatch');
renderPanel(store);
- fireEvent.click(screen.getByRole('radio', { name: 'Green' }));
+ fireEvent.click(screen.getByRole('radio', { name: 'Custom' }));
// No additional dispatches beyond what React-Redux did to subscribe.
expect(dispatchSpy).not.toHaveBeenCalled();
- expect(store.getState().mascot.color).toBe('green');
+ expect(store.getState().mascot.color).toBe('custom');
});
it('invokes navigateBack from the header back button', () => {
@@ -130,14 +141,14 @@ describe('MascotPanel — mascotSlice rehydrate guard', () => {
it('ignores REHYDRATE actions for other slice keys', () => {
const store = configureStore({ reducer: { mascot: mascotReducer } });
store.dispatch(setMascotColor('navy'));
- store.dispatch({ type: REHYDRATE, key: 'someOtherSlice', payload: { color: 'green' } });
+ store.dispatch({ type: REHYDRATE, key: 'someOtherSlice', payload: { color: 'custom' } });
// Should remain navy — we only handle key === 'mascot'.
expect(store.getState().mascot.color).toBe('navy');
});
it('renders the rehydrated color as selected in the panel', () => {
const store = configureStore({ reducer: { mascot: mascotReducer } });
- store.dispatch({ type: REHYDRATE, key: 'mascot', payload: { color: 'green' } });
+ store.dispatch({ type: REHYDRATE, key: 'mascot', payload: { color: 'custom' } });
render(
@@ -145,7 +156,7 @@ describe('MascotPanel — mascotSlice rehydrate guard', () => {
);
- expect(screen.getByRole('radio', { name: 'Green' })).toHaveAttribute('aria-checked', 'true');
+ expect(screen.getByRole('radio', { name: 'Custom' })).toHaveAttribute('aria-checked', 'true');
expect(screen.getByRole('radio', { name: 'Yellow' })).toHaveAttribute('aria-checked', 'false');
});
diff --git a/app/src/features/human/HumanPage.test.tsx b/app/src/features/human/HumanPage.test.tsx
index a13582fa7c..a3764f05b6 100644
--- a/app/src/features/human/HumanPage.test.tsx
+++ b/app/src/features/human/HumanPage.test.tsx
@@ -11,9 +11,9 @@ import { act, fireEvent, render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import chatRuntimeReducer, { setToolTimelineForThread } from '../../store/chatRuntimeSlice';
+import chatRuntimeReducer from '../../store/chatRuntimeSlice';
import mascotReducer, { setCustomMascotGifUrl } from '../../store/mascotSlice';
-import threadReducer, { setSelectedThread } from '../../store/threadSlice';
+import threadReducer from '../../store/threadSlice';
// ── Static import (after mocks are hoisted) ──────────────────────────────
import HumanPage from './HumanPage';
@@ -23,15 +23,19 @@ vi.mock('../../pages/Conversations', () => ({
default: () => ,
}));
-vi.mock('./Mascot', () => ({
- YellowMascot: () => ,
- CustomGifMascot: ({ src, face }: { src: string; face?: string }) => (
-
- ),
- Ghosty: ({ face, bodyColor }: { face?: string; bodyColor?: string }) => (
-
- ),
-}));
+vi.mock('./Mascot', async importOriginal => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ RiveMascot: () => ,
+ CustomGifMascot: ({ src, face }: { src: string; face?: string }) => (
+
+ ),
+ Ghosty: ({ face, bodyColor }: { face?: string; bodyColor?: string }) => (
+
+ ),
+ };
+});
vi.mock('./useHumanMascot', () => ({ useHumanMascot: () => ({ face: 'idle', visemes: [] }) }));
@@ -105,44 +109,6 @@ describe('HumanPage — speak-replies localStorage persistence', () => {
expect(checkbox).toBeChecked();
});
- it('renders sub-mascots for the selected thread subagent timeline', () => {
- const store = buildMinimalStore();
- store.dispatch(setSelectedThread('thread-subagents'));
- store.dispatch(
- setToolTimelineForThread({
- threadId: 'thread-subagents',
- entries: [
- {
- id: 'thread-subagents:subagent:sub-1:researcher',
- name: 'subagent:researcher',
- round: 1,
- status: 'running',
- detail: 'Research the latest docs and report back.',
- subagent: {
- taskId: 'sub-1',
- agentId: 'researcher',
- childIteration: 1,
- childMaxIterations: 3,
- toolCalls: [],
- },
- },
- ],
- })
- );
-
- renderHumanPage(store);
-
- expect(screen.getByTestId('sub-mascot-layer')).toBeInTheDocument();
- expect(
- screen.getByRole('status', { name: /researcher subagent running/i })
- ).toBeInTheDocument();
- // The bubble renders only the label; activity moved to the title tooltip.
- expect(screen.getByText('Researcher')).toBeInTheDocument();
- // Activity is in the title attribute of the bubble, not visible body text.
- const bubble = screen.getByTestId('sub-mascot-bubble');
- expect(bubble).toHaveAttribute('title', expect.stringContaining('Iteration 1/3'));
- });
-
it('renders a custom GIF mascot when one is configured', () => {
const store = buildMinimalStore();
store.dispatch(setCustomMascotGifUrl('https://example.com/avatar.gif'));
diff --git a/app/src/features/human/HumanPage.tsx b/app/src/features/human/HumanPage.tsx
index 65c8036bc1..960a9886ed 100644
--- a/app/src/features/human/HumanPage.tsx
+++ b/app/src/features/human/HumanPage.tsx
@@ -1,20 +1,19 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import Conversations from '../../pages/Conversations';
-import type { ToolTimelineEntry } from '../../store/chatRuntimeSlice';
import { useAppSelector } from '../../store/hooks';
-import { selectCustomMascotGifUrl, selectMascotColor } from '../../store/mascotSlice';
-import { CustomGifMascot, YellowMascot } from './Mascot';
-import { SubMascotLayer } from './SubMascotLayer';
+import {
+ selectCustomMascotGifUrl,
+ selectCustomPrimaryColor,
+ selectCustomSecondaryColor,
+ selectMascotColor,
+} from '../../store/mascotSlice';
+import { CustomGifMascot, getMascotPalette, hexToArgbInt, RiveMascot } from './Mascot';
import { useHumanMascot } from './useHumanMascot';
const SPEAK_REPLIES_KEY = 'human.speakReplies';
-// Stable empty reference so useAppSelector's === equality doesn't force a re-render
-// of SubMascotLayer on every store update when no subagent timeline is active.
-const EMPTY_TIMELINE: ToolTimelineEntry[] = [];
-
const HumanPage = () => {
const { t } = useT();
const [speakReplies, setSpeakReplies] = useState(() => {
@@ -26,19 +25,21 @@ const HumanPage = () => {
window.localStorage.setItem(SPEAK_REPLIES_KEY, speakReplies ? '1' : '0');
}, [speakReplies]);
- // Visemes are intentionally unused — the YellowMascot has its own talking lipsync.
const { face } = useHumanMascot({ speakReplies });
const mascotColor = useAppSelector(selectMascotColor);
+ const customPrimary = useAppSelector(selectCustomPrimaryColor);
+ const customSecondary = useAppSelector(selectCustomSecondaryColor);
const customMascotGifUrl = useAppSelector(selectCustomMascotGifUrl);
- const subMascotTimeline = useAppSelector(state => {
- const threadId = state.thread.selectedThreadId ?? state.thread.activeThreadId;
- return threadId
- ? (state.chatRuntime.toolTimelineByThread[threadId] ?? EMPTY_TIMELINE)
- : EMPTY_TIMELINE;
- });
+ const palette = getMascotPalette(mascotColor);
+ const primaryColor = useMemo(
+ () => hexToArgbInt(mascotColor === 'custom' ? customPrimary : palette.bodyFill),
+ [mascotColor, customPrimary, palette]
+ );
+ const secondaryColor = useMemo(
+ () => hexToArgbInt(mascotColor === 'custom' ? customSecondary : palette.neckShadowColor),
+ [mascotColor, customSecondary, palette]
+ );
- // Sidebar reserves ~436px (420px panel + 16px gutter) on the right; the
- // mascot stage takes the remaining width so the two never overlap.
return (
{
{customMascotGifUrl ? (
) : (
-
+
)}
-
diff --git a/app/src/features/human/Mascot/RiveMascot.tsx b/app/src/features/human/Mascot/RiveMascot.tsx
new file mode 100644
index 0000000000..a2349f0703
--- /dev/null
+++ b/app/src/features/human/Mascot/RiveMascot.tsx
@@ -0,0 +1,89 @@
+import {
+ Fit,
+ Layout,
+ useRive,
+ useViewModel,
+ useViewModelInstance,
+ useViewModelInstanceBoolean,
+ useViewModelInstanceColor,
+ useViewModelInstanceString,
+} from '@rive-app/react-webgl2';
+import { type FC, useEffect } from 'react';
+
+import type { MascotFace } from './Ghosty';
+import type { VisemeId } from './visemes';
+
+export interface RiveMascotProps {
+ face?: MascotFace;
+ size?: number | string;
+ primaryColor?: number;
+ secondaryColor?: number;
+ viseme?: VisemeId;
+}
+
+const SPEAKING_FACES: ReadonlySet = new Set(['speaking', 'happy']);
+
+const FACE_TO_POSE: Record = {
+ idle: 'idle',
+ normal: 'idle',
+ sleep: 'sleeping',
+ listening: 'idle',
+ thinking: 'thinking',
+ confused: 'thinking',
+ speaking: 'idle',
+ happy: 'idle',
+ concerned: 'idle',
+};
+
+const RIVE_LAYOUT = new Layout({ fit: Fit.Contain });
+
+export const RiveMascot: FC = ({
+ face = 'idle',
+ size = '100%',
+ primaryColor,
+ secondaryColor,
+ viseme = 'REST',
+}) => {
+ const { rive, RiveComponent } = useRive({
+ src: '/tiny_mascot.riv',
+ stateMachines: 'Main State Machine',
+ autoplay: true,
+ layout: RIVE_LAYOUT,
+ });
+
+ const viewModel = useViewModel(rive, { useDefault: true });
+ const vmInstance = useViewModelInstance(viewModel, { useDefault: true, rive });
+ const { setValue: setMouthOpen } = useViewModelInstanceBoolean('mouthOpen', vmInstance);
+ const { setValue: setPose } = useViewModelInstanceString('pose', vmInstance);
+ const { setValue: setViseme } = useViewModelInstanceString('viseme', vmInstance);
+ const { setValue: setPrimaryColor } = useViewModelInstanceColor('primaryColor', vmInstance);
+ const { setValue: setSecondaryColor } = useViewModelInstanceColor('secondaryColor', vmInstance);
+
+ useEffect(() => {
+ setMouthOpen(SPEAKING_FACES.has(face!));
+ setPose(FACE_TO_POSE[face!] ?? 'idle');
+ }, [face, setMouthOpen, setPose]);
+
+ useEffect(() => {
+ setViseme(viseme);
+ }, [viseme, setViseme]);
+
+ useEffect(() => {
+ if (primaryColor !== undefined) setPrimaryColor(primaryColor);
+ }, [primaryColor, setPrimaryColor]);
+
+ useEffect(() => {
+ if (secondaryColor !== undefined) setSecondaryColor(secondaryColor);
+ }, [secondaryColor, setSecondaryColor]);
+
+ return (
+
+
+
+ );
+};
diff --git a/app/src/features/human/Mascot/YellowMascot.test.tsx b/app/src/features/human/Mascot/YellowMascot.test.tsx
deleted file mode 100644
index d68da7957a..0000000000
--- a/app/src/features/human/Mascot/YellowMascot.test.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { render } from '@testing-library/react';
-import { describe, expect, it } from 'vitest';
-
-import { YellowMascot } from './YellowMascot';
-
-describe('', () => {
- it('renders an svg by default with the configured face data attribute', () => {
- const { container } = render();
- const host = container.querySelector('[data-face]') as HTMLElement;
- expect(host).not.toBeNull();
- expect(host.getAttribute('data-face')).toBe('idle');
- expect(container.querySelector('svg')).not.toBeNull();
- });
-
- it.each([
- ['idle', 'idle'],
- ['sleep', 'sleep'],
- ['speaking', 'speaking'],
- ['thinking', 'thinking'],
- ['confused', 'confused'],
- ] as const)('passes %s through to data-face', (face, expected) => {
- const { container } = render();
- const host = container.querySelector('[data-face]') as HTMLElement;
- expect(host.getAttribute('data-face')).toBe(expected);
- });
-
- it('renders the sleep face with an svg', () => {
- const { container } = render();
- const host = container.querySelector('[data-face="sleep"]') as HTMLElement;
- expect(host).not.toBeNull();
- expect(container.querySelector('svg')).not.toBeNull();
- });
-
- it('forwards a numeric size prop as a pixel width', () => {
- const { container } = render();
- const host = container.querySelector('[data-face]') as HTMLElement;
- expect(host.style.width).toBe('48px');
- });
-
- it('uses the requested mascot color palette in the rendered svg fills', () => {
- const { container: yellow } = render();
- const { container: navy } = render();
- const yellowFill = yellow.querySelector('path[fill]');
- const navyFill = navy.querySelector('path[fill]');
- expect(yellowFill).not.toBeNull();
- expect(navyFill).not.toBeNull();
- expect(yellowFill?.getAttribute('fill')).not.toBe(navyFill?.getAttribute('fill'));
- });
-});
diff --git a/app/src/features/human/Mascot/YellowMascot.tsx b/app/src/features/human/Mascot/YellowMascot.tsx
deleted file mode 100644
index 14b4112890..0000000000
--- a/app/src/features/human/Mascot/YellowMascot.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-import { type ComponentType, type FC, useMemo } from 'react';
-
-import type { MascotFace } from './Ghosty';
-import type { MascotColor } from './mascotPalette';
-import { FrameProvider, StaticFrameProvider } from './yellow/frameContext';
-import type { MascotProps as YellowMascotInnerProps } from './yellow/MascotCharacter';
-import { YellowMascotIdle } from './yellow/MascotIdle';
-import { YellowMascotTalking } from './yellow/MascotTalking';
-import { YellowMascotThinking } from './yellow/MascotThinking';
-
-export interface YellowMascotProps {
- /** High-level state from the agent/voice lifecycle. Mapped to a composition. */
- face?: MascotFace;
- /** Whether to show the wave arm. Only meaningful in idle/listening states. */
- arm?: 'wave' | 'none';
- /** Override SVG element size; defaults to filling the parent. */
- size?: number | string;
- /** Center opacity of the ground shadow gradient — pass through to MascotCharacter. */
- groundShadowOpacity?: number;
- /** Use the compact arm shading variant — pass through to MascotCharacter. */
- compactArmShading?: boolean;
- /** Mascot color palette. Defaults to yellow. */
- mascotColor?: MascotColor;
- /** Render a static (non-animated) pose. Skips the rAF tick used by
- * the default animated FrameProvider so decorative instances
- * (e.g. subagent indicators) don't churn frames. */
- static?: boolean;
-}
-
-const FPS = 30;
-// Logical canvas size reported via useVideoConfig() to the inner compositions.
-// They use width/height for layout math (e.g. transform origins). The actual
-// on-screen size comes from the wrapper div + the SVG's CSS width/height.
-const CANVAS = 1000;
-// Loop length per state. The Thinking variant we authored loops cleanly at 6s.
-const DURATION_FRAMES = FPS * 6;
-
-type ExtendedInnerProps = YellowMascotInnerProps & {
- groundShadowOpacity?: number;
- compactArmShading?: boolean;
-};
-
-interface Variant {
- component: ComponentType;
- inputProps: ExtendedInnerProps;
-}
-
-function variantForFace(
- face: MascotFace,
- arm: 'wave' | 'none',
- extras: Pick
-): Variant {
- const base: Pick<
- YellowMascotInnerProps,
- 'face' | 'recordingColor' | 'loadingColor' | 'greeting' | 'sleeping' | 'mascotColor'
- > = {
- face: 'normal',
- recordingColor: '#ff3b30',
- loadingColor: '#ffffff',
- greeting: false,
- sleeping: false,
- mascotColor: extras.mascotColor ?? 'yellow',
- };
- switch (face) {
- case 'sleep':
- return {
- component: YellowMascotIdle,
- inputProps: { ...base, sleeping: true, arm: 'none', talking: false, thinking: false },
- };
- case 'thinking':
- case 'confused':
- return {
- component: YellowMascotThinking,
- inputProps: { ...base, arm: 'steady', talking: false, thinking: true },
- };
- case 'speaking':
- case 'happy':
- return {
- component: YellowMascotTalking,
- inputProps: { ...base, arm: 'steady', talking: true, thinking: false },
- };
- case 'listening':
- case 'idle':
- case 'normal':
- case 'concerned':
- default:
- return {
- component: YellowMascotIdle,
- inputProps: { ...base, arm, talking: false, thinking: false },
- };
- }
-}
-
-export const YellowMascot: FC = ({
- face = 'idle',
- arm = 'none',
- size = '100%',
- groundShadowOpacity,
- compactArmShading,
- mascotColor = 'yellow',
- static: isStatic = false,
-}) => {
- const { Component, inputProps } = useMemo(() => {
- const variant = variantForFace(face, arm, { mascotColor });
- const merged: ExtendedInnerProps = {
- ...variant.inputProps,
- ...(groundShadowOpacity !== undefined ? { groundShadowOpacity } : {}),
- ...(compactArmShading !== undefined ? { compactArmShading } : {}),
- };
- return { Component: variant.component, inputProps: merged };
- }, [face, arm, mascotColor, groundShadowOpacity, compactArmShading]);
-
- return (
-
- {/* MascotCharacter sets its
- );
-};
diff --git a/app/src/features/human/Mascot/index.ts b/app/src/features/human/Mascot/index.ts
index 1d3816fdeb..b346f8ba79 100644
--- a/app/src/features/human/Mascot/index.ts
+++ b/app/src/features/human/Mascot/index.ts
@@ -2,9 +2,9 @@ export { Ghosty } from './Ghosty';
export type { GhostyProps, MascotFace } from './Ghosty';
export { CustomGifMascot } from './CustomGifMascot';
export type { CustomGifMascotProps } from './CustomGifMascot';
-export { YellowMascot } from './YellowMascot';
-export type { YellowMascotProps } from './YellowMascot';
+export { RiveMascot } from './RiveMascot';
+export type { RiveMascotProps } from './RiveMascot';
export { lerpViseme, VISEMES, visemePath } from './visemes';
export type { VisemeId, VisemeShape } from './visemes';
-export { getMascotPalette } from './mascotPalette';
+export { getMascotPalette, hexToArgbInt } from './mascotPalette';
export type { MascotColor, MascotPalette } from './mascotPalette';
diff --git a/app/src/features/human/Mascot/mascotPalette.test.ts b/app/src/features/human/Mascot/mascotPalette.test.ts
index 9400215f7c..5f04aa21e3 100644
--- a/app/src/features/human/Mascot/mascotPalette.test.ts
+++ b/app/src/features/human/Mascot/mascotPalette.test.ts
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
import { getMascotPalette } from './mascotPalette';
describe('getMascotPalette', () => {
- it.each(['yellow', 'burgundy', 'black', 'navy', 'green'] as const)(
+ it.each(['yellow', 'burgundy', 'black', 'navy', 'custom'] as const)(
'returns a populated palette for %s',
color => {
const palette = getMascotPalette(color);
diff --git a/app/src/features/human/Mascot/mascotPalette.ts b/app/src/features/human/Mascot/mascotPalette.ts
index ead40ccf4c..644ecf5d96 100644
--- a/app/src/features/human/Mascot/mascotPalette.ts
+++ b/app/src/features/human/Mascot/mascotPalette.ts
@@ -1,4 +1,4 @@
-export type MascotColor = 'yellow' | 'burgundy' | 'black' | 'navy' | 'green';
+export type MascotColor = 'yellow' | 'burgundy' | 'black' | 'navy' | 'custom';
export interface MascotPalette {
armHighlightMatrix: string;
@@ -56,18 +56,17 @@ const palettes: Record = {
headShadowMatrix: '0 0 0 0 0.0705882 0 0 0 0 0.14902 0 0 0 0 0.270588 0 0 0 1 0',
neckShadowColor: '#16324D',
},
- green: {
- armHighlightMatrix: '0 0 0 0 0.403922 0 0 0 0 0.654902 0 0 0 0 0.364706 0 0 0 1 0',
- armShadowMatrix: '0 0 0 0 0.113725 0 0 0 0 0.270588 0 0 0 0 0.117647 0 0 0 1 0',
- bodyFill: '#5FA64F',
- bodyHighlightMatrix: '0 0 0 0 0.403922 0 0 0 0 0.654902 0 0 0 0 0.364706 0 0 0 1 0',
- bodyShadowMatrix: '0 0 0 0 0.113725 0 0 0 0 0.270588 0 0 0 0 0.117647 0 0 0 1 0',
- headHighlightMatrix: '0 0 0 0 0.780392 0 0 0 0 0.894118 0 0 0 0 0.733333 0 0 0 1 0',
- headShadowMatrix: '0 0 0 0 0.113725 0 0 0 0 0.270588 0 0 0 0 0.117647 0 0 0 1 0',
- neckShadowColor: '#2E5A24',
- },
+ custom: YELLOW_PALETTE,
};
export function getMascotPalette(color: MascotColor): MascotPalette {
return palettes[color];
}
+
+export function hexToArgbInt(hex: string): number {
+ const h = hex.replace('#', '');
+ const r = parseInt(h.slice(0, 2), 16);
+ const g = parseInt(h.slice(2, 4), 16);
+ const b = parseInt(h.slice(4, 6), 16);
+ return ((0xff << 24) | (r << 16) | (g << 8) | b) >>> 0;
+}
diff --git a/app/src/features/human/Mascot/yellow/LoadingFace.tsx b/app/src/features/human/Mascot/yellow/LoadingFace.tsx
deleted file mode 100644
index 449ba50cb3..0000000000
--- a/app/src/features/human/Mascot/yellow/LoadingFace.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import React from 'react';
-
-// Spinning circular loading indicator that replaces the face.
-// Centered on the face area (cx=520, cy=545 in the body's local viewBox).
-export const LoadingFace: React.FC<{
- frame: number;
- fps: number;
- color: string;
- trackColor?: string;
-}> = ({ frame, fps, color, trackColor = '#ffffff' }) => {
- // One full rotation every 1.4 seconds.
- const rotation = ((frame / fps) * 360) / 1.4;
-
- const radius = 175;
- const stroke = 28;
- const circumference = 2 * Math.PI * radius;
- // The visible arc occupies ~70% of the circumference; the rest is the gap that spins.
- const arc = circumference * 0.7;
-
- return (
-
- {/* Background track. */}
-
-
- {/* Spinning progress arc. */}
-
-
-
-
- );
-};
diff --git a/app/src/features/human/Mascot/yellow/MascotCharacter.tsx b/app/src/features/human/Mascot/yellow/MascotCharacter.tsx
deleted file mode 100644
index a35334d06c..0000000000
--- a/app/src/features/human/Mascot/yellow/MascotCharacter.tsx
+++ /dev/null
@@ -1,906 +0,0 @@
-import { zColor } from '@remotion/zod-types';
-import React from 'react';
-import { AbsoluteFill, Easing, interpolate } from 'remotion';
-import { z } from 'zod';
-
-import { getMascotPalette, type MascotColor } from '../mascotPalette';
-import { useCurrentFrame, useVideoConfig } from './frameContext';
-import { LoadingFace } from './LoadingFace';
-import { RecordingFace } from './RecordingFace';
-
-export const mascotSchema = z.object({
- arm: z.enum(['wave', 'none', 'steady']).default('wave'),
- face: z.enum(['normal', 'recording', 'loading']).default('normal'),
- talking: z.boolean().default(false),
- sleeping: z.boolean().default(false),
- thinking: z.boolean().default(false),
- greeting: z.boolean().default(false),
- mascotColor: z.enum(['yellow', 'burgundy', 'black', 'navy', 'green']).default('yellow'),
- recordingColor: zColor().default('#ff3b30'),
- loadingColor: zColor().default('#ffffff'),
-});
-
-export type MascotProps = z.infer;
-
-/**
- * Mascot character — drives the custom yellow mascot SVG with the same
- * animation system as Ghosty: body bob, head-dot drift/squash, arm wave, blink.
- *
- * Use distinct `idPrefix` values if two instances appear in the same SVG tree
- * so filter/gradient IDs don't collide.
- */
-type ThinkingTiming = {
- /** Seconds at which the idle→thinking ramp begins. Default 1.0. */
- thinkInStartSec?: number;
- /** Seconds at which the idle→thinking ramp completes. Default 2.0. */
- thinkInEndSec?: number;
- /** Seconds at which the thinking→idle ramp begins. If unset, the pose holds. */
- thinkOutStartSec?: number;
- /** Seconds at which the thinking→idle ramp completes. Required if thinkOutStartSec is set. */
- thinkOutEndSec?: number;
-};
-
-export const MascotCharacter: React.FC<
- MascotProps & {
- idPrefix?: string;
- /** Center opacity of the ground shadow gradient. Defaults to 0.35;
- * bump up (e.g. 0.75) when the mascot is rendered very small (e.g.
- * the floating mascot window) so the shadow stays readable. */
- groundShadowOpacity?: number;
- /** When true, replaces the warm yellow/amber arm inner-shadow tints
- * with darker neutrals so the under-arm shading reads as a real
- * shadow at very small render sizes (instead of looking like a
- * bright halo). */
- compactArmShading?: boolean;
- } & ThinkingTiming
-> = ({
- arm = 'wave',
- face = 'normal',
- talking = false,
- sleeping = false,
- thinking = false,
- greeting = false,
- mascotColor = 'yellow',
- recordingColor = '#ff3b30',
- loadingColor = '#ffffff',
- idPrefix = 'mascot',
- groundShadowOpacity = 0.35,
- compactArmShading = false,
- thinkInStartSec = 1.0,
- thinkInEndSec = 2.0,
- thinkOutStartSec,
- thinkOutEndSec,
-}) => {
- const palette = getMascotPalette(mascotColor as MascotColor);
- // Arm-shadow color matrices. Default is the warm yellow→amber pair
- // that matches the mascot's hand-painted look at full size; in
- // compact mode (small render) we kill the yellow highlight and turn
- // the amber shadow into a true black so the under-arm reads as a
- // single dark mass instead of a noisy halo at low pixel counts.
- const armHighlightMatrix = compactArmShading
- ? '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
- : palette.armHighlightMatrix;
- const rightArmShadowMatrix = compactArmShading
- ? '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0'
- : palette.armShadowMatrix;
- const leftArmShadowMatrix = compactArmShading
- ? '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0'
- : palette.armShadowMatrix.replace(/ 1 0$/, ' 0.8 0');
-
- const frame = useCurrentFrame();
- const { fps, width, height, durationInFrames } = useVideoConfig();
-
- // Snap each periodic oscillator to a whole number of cycles within
- // `durationInFrames` so the first and last frames match — the Player loops
- // back to frame 0, and any phase mismatch shows up as a visible pop.
- const totalSec = durationInFrames / fps;
- // Closest frequency (Hz) that completes an integer number of cycles in the duration.
- const loopHz = (targetHz: number): number =>
- Math.max(1, Math.round(targetHz * totalSec)) / totalSec;
- // Closest period (frames) that divides the duration into an integer number of cycles.
- const loopPeriod = (targetFrames: number): number =>
- durationInFrames / Math.max(1, Math.round(durationInFrames / targetFrames));
-
- // Convert the original `Math.sin((frame/fps) * π * X)` form: angular freq = X/2 Hz.
- // Replace X with 2 * loopHz(originalHz) to keep speed close to the design intent.
- const ang = (originalHz: number): number => 2 * Math.PI * loopHz(originalHz) * (frame / fps);
-
- // Gentle bob for the whole character — design freq 0.6 Hz.
- const bob = Math.sin(ang(0.6)) * 14;
-
- // Head dot drifts independently and squashes when pressing into the body.
- // Original used a single dotPhase with multiplied factors; split into two
- // independent loops so each snaps to an integer cycle count.
- const dotDriftX = ang(0.35); // was sin(dotPhase * 0.7) → 0.35 Hz
- const dotDriftY = ang(0.5); // was sin(dotPhase) → 0.5 Hz
- const dotDx = Math.sin(dotDriftX) * 6;
- const dotDy = Math.sin(dotDriftY) * 9;
- const press = Math.max(0, Math.sin(dotDriftY));
- const dotSquashY = 1 - 0.08 * press;
- const dotSquashX = 1 + 0.05 * press;
-
- // Right arm wave — keyframe-based hi-wave: 3 swings then a rest pause.
- // Period snaps to an integer divisor of the duration.
- const easeInOut = Easing.inOut(Easing.cubic);
- const wavePeriod = loopPeriod(Math.round(fps * 2.4));
- const frameInCycle = frame % wavePeriod;
- const wave =
- arm === 'wave'
- ? interpolate(
- frameInCycle,
- [
- 0,
- wavePeriod * 0.12,
- wavePeriod * 0.25,
- wavePeriod * 0.38,
- wavePeriod * 0.5,
- wavePeriod * 0.62,
- wavePeriod * 0.75,
- wavePeriod,
- ],
- [0, -9, 0, -7, 0, -5, 0, 0],
- { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: easeInOut }
- )
- : 0;
-
- // Left arm gentle sway — design freq 0.8 Hz.
- const leftSway = Math.sin(ang(0.8)) * 7;
-
- // Steady right arm sway — same freq, slight phase offset (offset is harmless
- // for loop-alignment as long as the base freq fits an integer cycle count).
- const steadySway = Math.sin(ang(0.8) + 0.3) * 6;
-
- // Lip sync — design freqs 1.5 and 2.3 Hz. Phase offset preserved.
- const talkA = Math.abs(Math.sin(ang(1.5)));
- const talkB = Math.abs(Math.sin(ang(2.3) + 1.2));
- const mouthOpen = talking ? Math.max(talkA, talkB * 0.8) : 0;
- // Tongue fades in only when mouth is open enough — prevents visible tongue during near-closed frames.
- const tongueOpacity = talking ? Math.min(1, Math.max(0, (mouthOpen - 0.15) / 0.35)) : 0;
-
- // Blink — period snaps to an integer divisor of the duration.
- const blinkPeriod = loopPeriod(Math.round(fps * 2.6));
- const blinkOffset = Math.round(blinkPeriod / 2);
- const inBlink = (frame + blinkOffset) % blinkPeriod < 6;
- const blinkScale = inBlink ? 0.12 : 1;
-
- // Sleep animation — slow eye-close then floating Zzz.
- const sleepStartFrame = sleeping ? Math.round(fps * 2.5) : 99999;
- const sleepFullFrame = sleeping ? Math.round(fps * 4.0) : 99999;
- const inSleepTransition = sleeping && frame >= sleepStartFrame;
- const sleepProgress = sleeping
- ? interpolate(frame, [sleepStartFrame, sleepFullFrame], [0, 1], {
- extrapolateLeft: 'clamp',
- extrapolateRight: 'clamp',
- easing: Easing.inOut(Easing.cubic),
- })
- : 0;
- const isAsleep = sleeping && frame >= sleepFullFrame;
-
- // Eye openness: normal blink while awake, slow droop during sleep transition.
- const eyeScale = inSleepTransition ? Math.max(0, 1 - sleepProgress) : blinkScale;
- // Suppress blink highlights mid-droop so pupils don't pop on/off.
- const effectiveInBlink = inSleepTransition ? false : inBlink;
- // Switch to sleep-arc eyes once eyelids have closed.
- const showSleepEyes = sleeping && eyeScale <= 0.06;
-
- // Floating Z letters — staggered, drift up and fade out.
- const zPeriod = Math.round(fps * 2.2);
- const zBaseStart = sleepFullFrame + Math.round(fps * 0.4);
- const getZ = (delay: number, baseX: number, fontSize: number) => {
- const startAt = zBaseStart + delay;
- if (!isAsleep || frame < startAt)
- return { x: baseX, y: 220 as number, opacity: 0 as number, fontSize };
- const cycleFrame = (frame - startAt) % zPeriod;
- const t = cycleFrame / zPeriod;
- return {
- x: baseX + t * 20,
- y: 220 - t * 120,
- opacity: interpolate(t, [0, 0.1, 0.72, 1], [0, 1, 0.85, 0]),
- fontSize,
- };
- };
- // Thinking animation — arm raises, head tilts, eyes shift up, mouth changes.
- // Ramp up from `thinkInStartSec` → `thinkInEndSec`. If thinkOutStartSec/EndSec
- // are provided, ramp back down so the pose returns to idle (loop-friendly).
- const thinkStartFrame = thinking ? Math.round(fps * thinkInStartSec) : 99999;
- const thinkFullFrame = thinking ? Math.round(fps * thinkInEndSec) : 99999;
- const hasOutRamp = thinking && thinkOutStartSec !== undefined && thinkOutEndSec !== undefined;
- const thinkOutStartFrame = hasOutRamp ? Math.round(fps * (thinkOutStartSec as number)) : 99999;
- const thinkOutEndFrame = hasOutRamp ? Math.round(fps * (thinkOutEndSec as number)) : 99999;
- const thinkInProgress = thinking
- ? interpolate(frame, [thinkStartFrame, thinkFullFrame], [0, 1], {
- extrapolateLeft: 'clamp',
- extrapolateRight: 'clamp',
- easing: Easing.inOut(Easing.cubic),
- })
- : 0;
- const thinkOutProgress = hasOutRamp
- ? interpolate(frame, [thinkOutStartFrame, thinkOutEndFrame], [0, 1], {
- extrapolateLeft: 'clamp',
- extrapolateRight: 'clamp',
- easing: Easing.inOut(Easing.cubic),
- })
- : 0;
- const thinkProgress = Math.max(0, thinkInProgress - thinkOutProgress);
- // "Fully in pose" — only true while held between in-ramp end and out-ramp start.
- const isThinking = thinking && thinkInProgress >= 1 && thinkOutProgress <= 0;
-
- // LEFT arm raises toward body/chin for thinking pose (matches reference: arm on viewer's left side).
- // Normal left arm droops at ~127° from +x axis; rotating −128° brings it to ~−1°
- // (nearly horizontal, pointing right toward body center — "hand near chin" read).
- const thinkArmOscillate = isThinking ? Math.sin((frame / fps) * Math.PI * 0.5) * 2 : 0;
- const effectiveLeftSway = thinking
- ? interpolate(thinkProgress, [0, 1], [leftSway, -128]) + thinkArmOscillate
- : leftSway;
-
- // Right arm stays in normal steady position while thinking.
- const rightSteadyAngle = steadySway;
-
- // Head tilts slightly toward raised arm (left = negative rotation in SVG).
- const headTilt = isThinking
- ? -4.5 + Math.sin((frame / fps) * Math.PI * 0.38) * 1.8
- : thinking
- ? interpolate(thinkProgress, [0, 1], [0, -4.5])
- : 0;
-
- // Eyes drift up-left — looking toward the raised arm / into the distance.
- const thinkEyeX = thinking ? thinkProgress * -6 : 0;
- const thinkEyeY = thinking ? thinkProgress * -9 : 0;
-
- // Greeting — right arm rises from resting to raised, then waves "hi" in a loop.
- const greetStartFrame = greeting ? Math.round(fps * 0.8) : 99999;
- const greetRaiseEnd = greeting ? Math.round(fps * 1.6) : 99999;
- const isGreeting = greeting && frame >= greetStartFrame;
- const greetRaiseProgress = greeting
- ? interpolate(frame, [greetStartFrame, greetRaiseEnd], [0, 1], {
- extrapolateLeft: 'clamp',
- extrapolateRight: 'clamp',
- easing: Easing.out(Easing.cubic),
- })
- : 0;
- // Raise: wave arm rotates from +52° (arm pointing right/down) up to 0° (arm raised).
- const greetRaiseAngle = interpolate(greetRaiseProgress, [0, 1], [52, 0]);
- // Hi wave: enthusiastic oscillation after the arm is fully raised.
- const greetWavePeriod = Math.round(fps * 1.3);
- const greetWaveFrame =
- greeting && frame > greetRaiseEnd ? (frame - greetRaiseEnd) % greetWavePeriod : 0;
- const greetWaveOscillate =
- greeting && frame > greetRaiseEnd
- ? interpolate(
- greetWaveFrame,
- [
- 0,
- greetWavePeriod * 0.25,
- greetWavePeriod * 0.5,
- greetWavePeriod * 0.75,
- greetWavePeriod,
- ],
- [0, -28, -2, -26, 0],
- {
- extrapolateLeft: 'clamp',
- extrapolateRight: 'clamp',
- easing: Easing.inOut(Easing.cubic),
- }
- )
- : 0;
- const greetArmAngle = greetRaiseAngle + greetWaveOscillate;
-
- const z1 = getZ(0, 605, 40);
- const z2 = getZ(Math.round(fps * 0.72), 624, 56);
- const z3 = getZ(Math.round(fps * 1.44), 643, 76);
-
- const size = Math.min(width, height) * 0.85;
- const p = (k: string) => `${idPrefix}-${k}`;
-
- return (
-
-
-
- );
-};
diff --git a/app/src/features/human/Mascot/yellow/MascotIdle.tsx b/app/src/features/human/Mascot/yellow/MascotIdle.tsx
deleted file mode 100644
index 025ac31042..0000000000
--- a/app/src/features/human/Mascot/yellow/MascotIdle.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import React from 'react';
-
-import { MascotCharacter, type MascotProps, mascotSchema } from './MascotCharacter';
-
-// Variant: idle mascot (no arm wave).
-export const yellowMascotIdleSchema = mascotSchema;
-export type YellowMascotIdleProps = MascotProps;
-
-export const YellowMascotIdle: React.FC = props => (
-
-);
diff --git a/app/src/features/human/Mascot/yellow/MascotTalking.tsx b/app/src/features/human/Mascot/yellow/MascotTalking.tsx
deleted file mode 100644
index 923d38b2d7..0000000000
--- a/app/src/features/human/Mascot/yellow/MascotTalking.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import React from 'react';
-
-import { MascotCharacter, type MascotProps, mascotSchema } from './MascotCharacter';
-
-// Variant: idle mascot (steady arms) with lip-sync mouth animation.
-export const yellowMascotTalkingSchema = mascotSchema;
-export type YellowMascotTalkingProps = MascotProps;
-
-export const YellowMascotTalking: React.FC = props => (
-
-);
diff --git a/app/src/features/human/Mascot/yellow/MascotThinking.tsx b/app/src/features/human/Mascot/yellow/MascotThinking.tsx
deleted file mode 100644
index d2f7d6fb3b..0000000000
--- a/app/src/features/human/Mascot/yellow/MascotThinking.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import type { FC } from 'react';
-import { z } from 'zod';
-
-import { useVideoConfig } from './frameContext';
-import { MascotCharacter, mascotSchema } from './MascotCharacter';
-
-export const yellowMascotThinkingSchema = mascotSchema.extend({
- thinking: z.boolean().default(true),
-});
-export type YellowMascotThinkingProps = z.infer;
-
-// Variant: starts idle, ramps into a thinking pose, holds, then ramps back to idle —
-// so the first and last frames match and the composition loops cleanly.
-// Ramp-in starts almost immediately so the action reads quickly.
-export const YellowMascotThinking: FC = props => {
- const { fps, durationInFrames } = useVideoConfig();
- const totalSec = durationInFrames / fps;
-
- // Quick entrance so the pose is visible early in the loop.
- const thinkInStartSec = 0.15;
- const thinkInEndSec = 0.85;
- // Exit ramps back to idle and finishes exactly on the last frame.
- const thinkOutEndSec = totalSec;
- const thinkOutStartSec = Math.max(thinkInEndSec + 0.2, totalSec - 0.85);
-
- return (
-
- );
-};
diff --git a/app/src/features/human/Mascot/yellow/RecordingFace.tsx b/app/src/features/human/Mascot/yellow/RecordingFace.tsx
deleted file mode 100644
index 816c052de2..0000000000
--- a/app/src/features/human/Mascot/yellow/RecordingFace.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import React from 'react';
-
-// Big pulsing red dot that replaces the face when Ghosty is recording.
-// Centered on the face area (cx=520, cy=545 in the body's local viewBox).
-export const RecordingFace: React.FC<{ frame: number; fps: number; color: string }> = ({
- frame,
- fps,
- color,
-}) => {
- // Smooth pulse: 0..1..0 over ~1.4s.
- const t = (frame / fps) * Math.PI * (2 / 1.4);
- const pulse = 0.5 + 0.5 * Math.sin(t);
-
- const baseR = 190;
- const dotR = baseR + pulse * 10;
-
- return (
-
- {/* Outer glow halo — expands and fades as the pulse rises. */}
-
-
-
- {/* Solid red dot. */}
-
-
- {/* Specular highlight. */}
-
-
- );
-};
diff --git a/app/src/features/human/Mascot/yellow/frameContext.test.tsx b/app/src/features/human/Mascot/yellow/frameContext.test.tsx
deleted file mode 100644
index 43b1a94f71..0000000000
--- a/app/src/features/human/Mascot/yellow/frameContext.test.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-import { act, render } from '@testing-library/react';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-
-import { FrameProvider, useCurrentFrame, useVideoConfig } from './frameContext';
-
-interface RAFCallback {
- (now: number): void;
-}
-
-function mockRequestAnimationFrame() {
- const callbacks = new Map();
- let nextId = 1;
-
- const raf = vi.fn((cb: RAFCallback): number => {
- const id = nextId++;
- callbacks.set(id, cb);
- return id;
- });
- const caf = vi.fn((id: number) => {
- callbacks.delete(id);
- });
-
- const tickTo = (now: number) => {
- const pending = Array.from(callbacks.entries());
- callbacks.clear();
- for (const [, cb] of pending) cb(now);
- };
-
- return { raf, caf, tickTo };
-}
-
-describe('frameContext', () => {
- let original: {
- raf: typeof window.requestAnimationFrame;
- caf: typeof window.cancelAnimationFrame;
- };
- let mock: ReturnType;
-
- beforeEach(() => {
- mock = mockRequestAnimationFrame();
- original = { raf: window.requestAnimationFrame, caf: window.cancelAnimationFrame };
- window.requestAnimationFrame = mock.raf as unknown as typeof window.requestAnimationFrame;
- window.cancelAnimationFrame = mock.caf as unknown as typeof window.cancelAnimationFrame;
- });
-
- afterEach(() => {
- window.requestAnimationFrame = original.raf;
- window.cancelAnimationFrame = original.caf;
- });
-
- it('exposes the configured video config to consumers', () => {
- let captured: ReturnType | null = null;
- const Probe = () => {
- captured = useVideoConfig();
- return null;
- };
- render(
-
-
-
- );
- expect(captured).toEqual({ fps: 30, width: 500, height: 500, durationInFrames: 180 });
- });
-
- it('starts at frame 0 and advances based on elapsed time', () => {
- let frame = -1;
- const Probe = () => {
- frame = useCurrentFrame();
- return null;
- };
- render(
-
-
-
- );
- // First render before any rAF tick.
- expect(frame).toBe(0);
- // Advance 0.5s — at 30fps this is frame 15.
- act(() => mock.tickTo(0));
- act(() => mock.tickTo(500));
- expect(frame).toBe(15);
- // Advance another 0.5s — frame 30.
- act(() => mock.tickTo(1000));
- expect(frame).toBe(30);
- });
-
- it('loops back to frame 0 after durationInFrames', () => {
- let frame = -1;
- const Probe = () => {
- frame = useCurrentFrame();
- return null;
- };
- render(
-
-
-
- );
- act(() => mock.tickTo(0));
- // 2 seconds at 30fps = 60 frames → wraps to 0.
- act(() => mock.tickTo(2000));
- expect(frame).toBe(0);
- // 2.5s = 75 frames → 75 % 60 = 15.
- act(() => mock.tickTo(2500));
- expect(frame).toBe(15);
- });
-
- it('throws when useVideoConfig is used outside FrameProvider', () => {
- const Probe = () => {
- useVideoConfig();
- return null;
- };
- // Suppress React's error logging for this throw-on-render case.
- const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
- expect(() => render()).toThrow(/useVideoConfig/);
- errSpy.mockRestore();
- });
-});
diff --git a/app/src/features/human/Mascot/yellow/frameContext.tsx b/app/src/features/human/Mascot/yellow/frameContext.tsx
deleted file mode 100644
index 55b8c54de6..0000000000
--- a/app/src/features/human/Mascot/yellow/frameContext.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-import {
- createContext,
- type FC,
- type ReactNode,
- useContext,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from 'react';
-
-/**
- * Local replacements for Remotion's `useCurrentFrame` and `useVideoConfig`.
- *
- * `@remotion/player` was reliably starting only after the user blurred and
- * refocused the window in CEF — its internal play() races with audio-context /
- * focus-event scheduling on cold mount and the SVG paints frame 0 then sits
- * idle. Since the mascot compositions only use `useCurrentFrame` /
- * `useVideoConfig` from Remotion (everything else is pure utilities like
- * `interpolate` / `Easing`), we drive frame ticks ourselves via
- * requestAnimationFrame and feed both hooks via plain React context.
- */
-
-export interface FrameConfig {
- fps: number;
- width: number;
- height: number;
- durationInFrames: number;
-}
-
-// Exported so callers (e.g. the meet camera frame producer) can plug in
-// a non-rAF tick source — rAF is throttled when the main window is
-// backgrounded behind another Tauri window, which freezes the mascot.
-export const FrameContext = createContext(0);
-export const FrameConfigContext = createContext(null);
-
-export const useCurrentFrame = (): number => useContext(FrameContext);
-
-export const useVideoConfig = (): FrameConfig => {
- const cfg = useContext(FrameConfigContext);
- if (!cfg) {
- throw new Error('useVideoConfig() must be used inside ');
- }
- return cfg;
-};
-
-interface FrameProviderProps extends FrameConfig {
- children: ReactNode;
-}
-
-export const FrameProvider: FC = ({
- fps,
- width,
- height,
- durationInFrames,
- children,
-}) => {
- const [frame, setFrame] = useState(0);
- const startRef = useRef(null);
-
- useEffect(() => {
- let raf = 0;
- const tick = (now: number) => {
- if (startRef.current === null) startRef.current = now;
- const elapsed = now - startRef.current;
- const next = Math.floor((elapsed / 1000) * fps) % durationInFrames;
- setFrame(prev => (prev === next ? prev : next));
- raf = window.requestAnimationFrame(tick);
- };
- raf = window.requestAnimationFrame(tick);
- return () => window.cancelAnimationFrame(raf);
- }, [fps, durationInFrames]);
-
- const config = useMemo(
- () => ({ fps, width, height, durationInFrames }),
- [fps, width, height, durationInFrames]
- );
-
- return (
-
- {children}
-
- );
-};
-
-/**
- * Static variant of {@link FrameProvider} — pins the frame at 0 and never
- * schedules a requestAnimationFrame. Use this for decorative mascot
- * instances (e.g. small subagent indicators) where motion would be
- * distracting and the per-frame rAF cost across N mascots is wasteful.
- */
-export const StaticFrameProvider: FC = ({
- fps,
- width,
- height,
- durationInFrames,
- children,
-}) => {
- const config = useMemo(
- () => ({ fps, width, height, durationInFrames }),
- [fps, width, height, durationInFrames]
- );
-
- return (
-
- {children}
-
- );
-};
diff --git a/app/src/features/human/SubMascotLayer.test.tsx b/app/src/features/human/SubMascotLayer.test.tsx
index 2152a85097..e97e68ac2b 100644
--- a/app/src/features/human/SubMascotLayer.test.tsx
+++ b/app/src/features/human/SubMascotLayer.test.tsx
@@ -1,9 +1,17 @@
import { render, screen, within } from '@testing-library/react';
-import { describe, expect, it } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
import type { ToolTimelineEntry } from '../../store/chatRuntimeSlice';
import { SubMascotLayer, subMascotModelsFromTimeline } from './SubMascotLayer';
+vi.mock('./Mascot', async importOriginal => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ RiveMascot: ({ face }: { face?: string }) => ,
+ };
+});
+
function subagentEntry(overrides: Partial = {}): ToolTimelineEntry {
return {
id: 'thread-1:subagent:sub-1:researcher',
diff --git a/app/src/features/human/SubMascotLayer.tsx b/app/src/features/human/SubMascotLayer.tsx
index 20add8d349..6c42afb943 100644
--- a/app/src/features/human/SubMascotLayer.tsx
+++ b/app/src/features/human/SubMascotLayer.tsx
@@ -2,7 +2,7 @@ import debug from 'debug';
import { type FC, useMemo } from 'react';
import type { ToolTimelineEntry, ToolTimelineEntryStatus } from '../../store/chatRuntimeSlice';
-import { type MascotFace, YellowMascot } from './Mascot';
+import { type MascotFace, RiveMascot } from './Mascot';
import type { MascotColor } from './Mascot/mascotPalette';
const subMascotLog = debug('human:sub-mascots');
@@ -10,13 +10,7 @@ const subMascotLog = debug('human:sub-mascots');
const MAX_SUB_MASCOTS = 5;
const ACTIVITY_LIMIT = 74;
-const SUB_MASCOT_COLORS: readonly MascotColor[] = [
- 'yellow',
- 'green',
- 'navy',
- 'burgundy',
- 'black',
-] as const;
+const SUB_MASCOT_COLORS: readonly MascotColor[] = ['yellow', 'navy', 'burgundy', 'black'] as const;
const POSITIONS = [
{ left: '72%', top: '18%' },
@@ -166,7 +160,7 @@ export const SubMascotLayer: FC = ({ entries }) => {
model.status === 'running' ? 'opacity-100' : 'opacity-75',
].join(' ')}>
-
+
` historically failed to start cold inside CEF
- * (see `app/src/features/human/Mascot/yellow/frameContext.tsx`); the
- * project replaced it with a local `FrameProvider` that drives ticks
- * via `requestAnimationFrame`. The compositions render to live SVG,
- * which we rasterize per frame: serialize → data URI → `
![]()
` decode
- * → drawImage → JPEG blob.
- */
-
-const PRODUCER_FPS = 24; // 24 fps is plenty for "lifelike" and gives
-// per-frame serialize+encode budget headroom — at 30 fps the SVG decode
-// occasionally backs up on slower machines and frames pile up. The
-// bridge consumer redraws its canvas at 30 fps regardless, repeating
-// our latest frame between producer ticks.
-
-// Producer renders at a *lower* resolution than the bridge canvas
-// (640×480) to keep SVG rasterization cheap. The bridge cover-fits
-// our 320×240 output up to 640×480, which is fine — the YellowMascot
-// SVG is vector and the user is watching a small video tile in Meet
-// that goes through Meet's own encoder, so source resolution is
-// invisible past ~360p anyway.
-//
-// Empirically (instrumented in the producer diag JSON): rendering at
-// 640×480 took ~1000 ms/frame on this hardware (img.decode of the
-// rich SVG dominates), pinning the producer to 1 fps. Halving each
-// dimension is a 4× rasterize speedup.
+const PRODUCER_FPS = 24;
const FRAME_W = 320;
const FRAME_H = 240;
const JPEG_QUALITY = 0.7;
-// Mascot inner-canvas dimensions. Mirrors the values YellowMascot
-// passes to FrameProvider — keep in sync if those change.
-const MASCOT_CANVAS = 1000;
-const MASCOT_LOOP_FRAMES = PRODUCER_FPS * 6;
-
interface BusSession {
requestId: string;
port: number;
@@ -116,43 +56,63 @@ export const MascotFrameProducer: FC = () => {
const ProducerSession: FC<{ session: BusSession }> = ({ session }) => {
const hostRef = useRef
(null);
- const mascotColor = useAppSelector(selectMascotColor);
const wsRef = useRef(null);
const wsReadyRef = useRef(false);
const stoppedRef = useRef(false);
const inflightRef = useRef(false);
- const sentFramesRef = useRef(0);
- // Frame counter feeding our own FrameContext below. We DON'T use the
- // shared `` wrapper because it ticks via
- // requestAnimationFrame, which Chromium throttles when the main
- // openhuman window is backgrounded behind the Meet window — the
- // mascot would freeze the moment the user clicks into Meet. The
- // worker tick below advances this state from `Date.now()` instead,
- // which keeps running regardless of focus.
- const [frame, setFrame] = useState(0);
- const startTimeRef = useRef(null);
- const frameAtTickRef = useRef(0);
+
+ const captureFrame = useCallback(async () => {
+ if (stoppedRef.current || !wsReadyRef.current || inflightRef.current) return;
+ const host = hostRef.current;
+ if (!host) return;
+ const canvas = host.querySelector('canvas');
+ if (!canvas) return;
+
+ inflightRef.current = true;
+ try {
+ const offscreen = new OffscreenCanvas(FRAME_W, FRAME_H);
+ const ctx = offscreen.getContext('2d');
+ if (!ctx) return;
+
+ const grad = ctx.createRadialGradient(
+ FRAME_W / 2,
+ FRAME_H / 2,
+ 0,
+ FRAME_W / 2,
+ FRAME_H / 2,
+ Math.max(FRAME_W, FRAME_H) * 0.7
+ );
+ grad.addColorStop(0, '#FBF3D9');
+ grad.addColorStop(1, '#EFE3B8');
+ ctx.fillStyle = grad;
+ ctx.fillRect(0, 0, FRAME_W, FRAME_H);
+
+ const inset = 0.06;
+ const fitW = FRAME_W * (1 - 2 * inset);
+ const fitH = FRAME_H * (1 - 2 * inset);
+ const scale = Math.min(fitW / canvas.width, fitH / canvas.height);
+ const dw = canvas.width * scale;
+ const dh = canvas.height * scale;
+ const dx = (FRAME_W - dw) / 2;
+ const dy = (FRAME_H - dh) / 2;
+ ctx.drawImage(canvas, dx, dy, dw, dh);
+
+ const blob = await offscreen.convertToBlob({ type: 'image/jpeg', quality: JPEG_QUALITY });
+ const buffer = await blob.arrayBuffer();
+ const ws = wsRef.current;
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(buffer);
+ }
+ } catch (err) {
+ console.warn('[meet-video-producer] capture failed', err);
+ } finally {
+ inflightRef.current = false;
+ }
+ }, []);
useEffect(() => {
stoppedRef.current = false;
- // ── Background-throttle defeater: muted autoplaying