feat(mascot): replace SVG animations with Rive renderer (#2659)

This commit is contained in:
Steven Enamakel
2026-05-25 21:44:13 -07:00
committed by GitHub
parent e05cab9bb2
commit f946d10dde
46 changed files with 430 additions and 1980 deletions
+2 -1
View File
@@ -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"
Binary file not shown.
@@ -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 (
<div>
<SettingsHeader
@@ -290,6 +310,17 @@ const MascotPanel = () => {
/>
<div className="p-4 space-y-4">
<div className="flex justify-center">
<div style={{ width: 180, height: 180 }}>
<RiveMascot
face="idle"
size={180}
primaryColor={primaryColorArgb}
secondaryColor={secondaryColorArgb}
/>
</div>
</div>
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
{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 }
}
/>
<span className="text-xs text-stone-700 dark:text-neutral-200">{label}</span>
</button>
@@ -337,6 +374,38 @@ const MascotPanel = () => {
</div>
)}
</div>
{activeColor === 'custom' && (
<div className="mt-3 bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
<label className="flex items-center gap-3">
<input
type="color"
value={customPrimary}
onChange={e => dispatch(setCustomPrimaryColor(e.target.value))}
className="w-8 h-8 rounded-md border border-stone-200 dark:border-neutral-700 cursor-pointer p-0"
/>
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('settings.mascot.primaryColor')}
</span>
<code className="ml-auto text-[11px] font-mono text-stone-400 dark:text-neutral-500">
{customPrimary}
</code>
</label>
<label className="flex items-center gap-3">
<input
type="color"
value={customSecondary}
onChange={e => dispatch(setCustomSecondaryColor(e.target.value))}
className="w-8 h-8 rounded-md border border-stone-200 dark:border-neutral-700 cursor-pointer p-0"
/>
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('settings.mascot.secondaryColor')}
</span>
<code className="ml-auto text-[11px] font-mono text-stone-400 dark:text-neutral-500">
{customSecondary}
</code>
</label>
</div>
)}
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
{t('settings.mascot.colorDesc')}
</p>
@@ -24,6 +24,17 @@ vi.mock('../../../../services/mascotService', () => ({
getCachedMascotDetail: (...args: unknown[]) => getCachedMascotDetailMock(...args),
}));
vi.mock('../../../../features/human/Mascot', async importOriginal => {
const actual = await importOriginal<typeof import('../../../../features/human/Mascot')>();
return {
...actual,
RiveMascot: () => <div data-testid="rive-mascot-preview" />,
CustomGifMascot: ({ src }: { src: string }) => (
<img data-testid="custom-gif-mascot" src={src} alt="" />
),
};
});
vi.mock('../../../../features/human/Mascot/backend/BackendMascot', () => ({
BackendMascot: ({ mascot }: { mascot: { id: string } }) => (
<div data-testid={`backend-mascot-preview-${mascot.id}`} />
@@ -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(
<Provider store={store}>
<MemoryRouter>
@@ -145,7 +156,7 @@ describe('MascotPanel — mascotSlice rehydrate guard', () => {
</MemoryRouter>
</Provider>
);
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');
});
+15 -49
View File
@@ -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: () => <div data-testid="conversations-stub" />,
}));
vi.mock('./Mascot', () => ({
YellowMascot: () => <div data-testid="mascot-stub" />,
CustomGifMascot: ({ src, face }: { src: string; face?: string }) => (
<img data-testid="custom-gif-mascot" data-face={face} src={src} alt="" />
),
Ghosty: ({ face, bodyColor }: { face?: string; bodyColor?: string }) => (
<div data-testid="ghosty-submascot" data-face={face} data-body-color={bodyColor} />
),
}));
vi.mock('./Mascot', async importOriginal => {
const actual = await importOriginal<typeof import('./Mascot')>();
return {
...actual,
RiveMascot: () => <div data-testid="mascot-stub" />,
CustomGifMascot: ({ src, face }: { src: string; face?: string }) => (
<img data-testid="custom-gif-mascot" data-face={face} src={src} alt="" />
),
Ghosty: ({ face, bodyColor }: { face?: string; bodyColor?: string }) => (
<div data-testid="ghosty-submascot" data-face={face} data-body-color={bodyColor} />
),
};
});
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'));
+20 -20
View File
@@ -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<boolean>(() => {
@@ -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 (
<div className="absolute inset-0 bg-stone-100 dark:bg-neutral-950 overflow-hidden">
<div
@@ -54,9 +55,8 @@ const HumanPage = () => {
{customMascotGifUrl ? (
<CustomGifMascot src={customMascotGifUrl} face={face} />
) : (
<YellowMascot face={face} mascotColor={mascotColor} />
<RiveMascot face={face} primaryColor={primaryColor} secondaryColor={secondaryColor} />
)}
<SubMascotLayer entries={subMascotTimeline} />
</div>
</div>
@@ -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<MascotFace> = new Set(['speaking', 'happy']);
const FACE_TO_POSE: Record<MascotFace, string> = {
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<RiveMascotProps> = ({
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 (
<div
style={{
width: typeof size === 'number' ? `${size}px` : size,
height: typeof size === 'number' ? `${size}px` : size,
}}
data-face={face}>
<RiveComponent />
</div>
);
};
@@ -1,49 +0,0 @@
import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { YellowMascot } from './YellowMascot';
describe('<YellowMascot />', () => {
it('renders an svg by default with the configured face data attribute', () => {
const { container } = render(<YellowMascot />);
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(<YellowMascot face={face} />);
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(<YellowMascot face="sleep" />);
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(<YellowMascot size={48} />);
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(<YellowMascot />);
const { container: navy } = render(<YellowMascot mascotColor="navy" />);
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'));
});
});
@@ -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<ExtendedInnerProps>;
inputProps: ExtendedInnerProps;
}
function variantForFace(
face: MascotFace,
arm: 'wave' | 'none',
extras: Pick<YellowMascotInnerProps, 'mascotColor'>
): 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<YellowMascotProps> = ({
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 (
<div
className="mascot-yellow-host"
style={{
width: typeof size === 'number' ? `${size}px` : size,
aspectRatio: '1 / 1',
background: 'transparent',
position: 'relative',
}}
data-face={face}>
{/* MascotCharacter sets its <svg> to a fixed pixel size derived from
useVideoConfig().width, then wraps it in an AbsoluteFill that fills
our parent. With Player gone we override that fixed size via CSS so
the SVG fills its container — the viewBox handles vector scaling. */}
<style>{`
.mascot-yellow-host svg { width: 100% !important; height: 100% !important; }
`}</style>
{(() => {
const Provider = isStatic ? StaticFrameProvider : FrameProvider;
return (
<Provider
fps={FPS}
width={CANVAS}
height={CANVAS}
durationInFrames={face === 'sleep' ? FPS * 600 : DURATION_FRAMES}>
<Component {...inputProps} />
</Provider>
);
})()}
</div>
);
};
+3 -3
View File
@@ -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';
@@ -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);
+10 -11
View File
@@ -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<MascotColor, MascotPalette> = {
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;
}
@@ -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 (
<g transform={`translate(520 555)`}>
{/* Background track. */}
<circle
cx={0}
cy={0}
r={radius}
fill="none"
stroke={trackColor}
strokeOpacity={0.18}
strokeWidth={stroke}
/>
{/* Spinning progress arc. */}
<g transform={`rotate(${rotation})`}>
<circle
cx={0}
cy={0}
r={radius}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={`${arc} ${circumference - arc}`}
strokeDashoffset={0}
/>
</g>
</g>
);
};
@@ -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<typeof mascotSchema>;
/**
* 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 ~
// (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 (
<AbsoluteFill style={{ justifyContent: 'center', alignItems: 'center' }}>
<svg width={size} height={size} viewBox="0 0 1000 1000" style={{ overflow: 'visible' }}>
<defs>
{/* Ground shadow gradient. Center opacity is configurable via
`groundShadowOpacity` so callers rendering the mascot at a
very small size (e.g. the floating mascot window) can darken
the shadow without affecting the full-size views. */}
<radialGradient id={p('ground')} cx="0.5" cy="0.5" r="0.5">
<stop offset="0%" stopColor="#000000" stopOpacity={groundShadowOpacity} />
<stop
offset="60%"
stopColor="#000000"
stopOpacity={Math.max(0, groundShadowOpacity * 0.45)}
/>
<stop offset="100%" stopColor="#000000" stopOpacity="0" />
</radialGradient>
{/* filter0: body — inner shadows + grain texture */}
<filter
id={p('f0')}
x="90.3857"
y="238.634"
width="765.268"
height="762.131"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="17" dy="28" />
<feGaussianBlur stdDeviation="10.45" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values={palette.bodyHighlightMatrix} />
<feBlend mode="normal" in2="shape" result="effect1_innerShadow" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="-27" dy="-22" />
<feGaussianBlur stdDeviation="29.75" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values={palette.bodyShadowMatrix} />
<feBlend mode="normal" in2="effect1_innerShadow" result="effect2_innerShadow" />
</filter>
{/* filter1: head circle — inner shadows + grain texture */}
<filter
id={p('f1')}
x="379"
y="22"
width="233"
height="237"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="9" dy="2" />
<feGaussianBlur stdDeviation="5.65" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values={palette.headHighlightMatrix} />
<feBlend mode="normal" in2="shape" result="effect1_innerShadow" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="-2" dy="-13" />
<feGaussianBlur stdDeviation="19.7" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values={palette.headShadowMatrix} />
<feBlend mode="normal" in2="effect1_innerShadow" result="effect2_innerShadow" />
</filter>
{/* filter2: neck shadow 1 — blur */}
<filter
id={p('f2')}
x="423.5"
y="239.5"
width="153.771"
height="66.8604"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="11.25" />
</filter>
{/* filter3: neck shadow 2 — blur */}
<filter
id={p('f3')}
x="434.976"
y="217.946"
width="123.537"
height="57.3711"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="11.25" />
</filter>
{/* filter4: right arm — inner shadows + grain texture */}
<filter
id={p('f4')}
x="759.925"
y="474.413"
width="170.767"
height="268.758"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="-11" dy="28" />
<feGaussianBlur stdDeviation="11" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values={armHighlightMatrix} />
<feBlend mode="normal" in2="shape" result="effect1_innerShadow" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="-8" dy="1" />
<feGaussianBlur stdDeviation="4.25" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values={rightArmShadowMatrix} />
<feBlend mode="normal" in2="effect1_innerShadow" result="effect2_innerShadow" />
</filter>
{/* filter5: left arm — inner shadows + grain texture */}
<filter
id={p('f5')}
x="138.458"
y="555.812"
width="155.093"
height="272.386"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="1" dy="-20" />
<feGaussianBlur stdDeviation="7.55" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values={armHighlightMatrix} />
<feBlend mode="normal" in2="shape" result="effect1_innerShadow" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="3" dy="-8" />
<feGaussianBlur stdDeviation="3.55" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values={leftArmShadowMatrix} />
<feBlend mode="normal" in2="effect1_innerShadow" result="effect2_innerShadow" />
</filter>
{/* filter6-7: left eye highlights */}
<filter
id={p('f6')}
x="390.218"
y="433.891"
width="25.0341"
height="28.893"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="0.65" />
</filter>
<filter
id={p('f7')}
x="390.3"
y="434.3"
width="22.4"
height="23.4"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="0.35" />
</filter>
{/* filter8-10: right eye highlights */}
<filter
id={p('f8')}
x="570.859"
y="435.358"
width="27.0393"
height="29.1125"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="0.95" />
</filter>
<filter
id={p('f9')}
x="571.3"
y="436.3"
width="19.4"
height="20.4"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="0.35" />
</filter>
<filter
id={p('f10')}
x="574.668"
y="440.492"
width="10.9674"
height="13.0943"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="0.35" />
</filter>
{/* filter13: steady right arm (idle pose) — mirrors left arm, inner shadows + grain */}
<filter
id={p('f13')}
x="645"
y="555.9"
width="155.094"
height="272.386"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="1" dy="-20" />
<feGaussianBlur stdDeviation="7.55" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values={armHighlightMatrix} />
<feBlend mode="normal" in2="shape" result="effect1_innerShadow" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="0" dy="-8" />
<feGaussianBlur stdDeviation="3.55" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values={leftArmShadowMatrix} />
<feBlend mode="normal" in2="effect1_innerShadow" result="effect2_innerShadow" />
</filter>
{/* filter11-12: cheek highlights */}
<filter
id={p('f11')}
x="366.181"
y="492.2"
width="15.6322"
height="13.601"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="0.9" />
</filter>
<filter
id={p('f12')}
x="618.2"
y="495.2"
width="15.6322"
height="13.601"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="0.9" />
</filter>
</defs>
{/* Ground shadow — scales with bob so it feels grounded. */}
<g transform={`translate(500, 975) scale(${1 - bob / 600}, 1)`}>
<ellipse cx={0} cy={0} rx={300} ry={28} fill={`url(#${p('ground')})`} />
</g>
{/* Everything bobs together. */}
<g transform={`translate(0, ${bob})`}>
{/* Head dot — drifts + squashes independently inside the bob group. */}
<g
transform={
`translate(${dotDx}, ${dotDy}) ` +
`translate(493 145) scale(${dotSquashX} ${dotSquashY}) translate(-493 -145)`
}>
<circle cx={493} cy={145} r={110} fill={palette.bodyFill} filter={`url(#${p('f1')})`} />
</g>
{/* Body */}
<path
d="M270.548 382.714C175.869 479.647 86.1402 654.573 127.915 829.517C145.272 881.371 165.202 911.976 222.935 941.975C253.337 957.772 327.5 950.5 375.544 921.664L445.394 890.456C490.742 873.851 509.572 876.412 538.5 889.192C577.029 910.413 587.5 931.5 649.207 964.222C729.487 1006.79 793.127 956.041 817.514 889.192C874.808 742.915 814.514 422.978 650.331 310.479C516.054 226.594 403.003 247.226 270.548 382.714Z"
fill={palette.bodyFill}
filter={`url(#${p('f0')})`}
/>
{/* Waving right arm — normal wave OR greeting raise+hi-wave. */}
{(arm === 'wave' || isGreeting) && (
<g transform={`rotate(${isGreeting ? greetArmAngle : wave}, 776, 568)`}>
<path
d="M821.855 513.95C798.846 545.418 795.5 553 776.706 568C760.334 581.067 781.974 653.709 801.375 710.888C805.052 721.724 819.237 724.693 827.147 716.425C860.877 681.172 917.862 621.391 924.689 572.869C939.558 467.192 868.275 454.188 821.855 513.95Z"
fill={palette.bodyFill}
filter={`url(#${p('f4')})`}
/>
</g>
)}
{/* Steady right arm — hidden once greeting raise begins. */}
{arm === 'steady' && !isGreeting && (
<g transform={`rotate(${rightSteadyAngle}, 655, 709)`}>
<path
d="M680.851 773.156C666.823 736.786 665.565 728.594 651.321 709.221C638.913 692.343 678.709 627.834 712.32 577.674C718.689 568.167 733.158 568.991 738.645 579.033C762.04 621.848 801.508 694.398 795.474 743.024C782.333 848.93 710.122 842.939 680.851 773.156Z"
fill={palette.bodyFill}
filter={`url(#${p('f13')})`}
/>
</g>
)}
{/* Left arm — gentle sway in idle; rotates up toward body center while thinking. */}
<g transform={`rotate(${effectiveLeftSway}, 290, 700)`}>
<path
d="M257.7 773.068C271.728 736.698 272.987 728.506 287.23 709.133C299.638 692.255 259.842 627.746 226.232 577.586C219.862 568.08 205.393 568.903 199.906 578.945C176.511 621.76 137.044 694.31 143.077 742.936C156.218 848.842 228.429 842.851 257.7 773.068Z"
fill={palette.bodyFill}
filter={`url(#${p('f5')})`}
/>
</g>
{/* Neck shadow details */}
<g opacity={0.4} filter={`url(#${p('f2')})`}>
<path
d="M450.376 270.172C464.042 264.005 502.076 255.372 544.876 270.172C598.376 288.672 415.876 288.172 450.376 270.172Z"
fill={palette.neckShadowColor}
/>
</g>
<g opacity={0.4} filter={`url(#${p('f3')})`}>
<path
d="M533.5 245.499C524.956 248.602 489.943 257.335 463.186 249.888C429.739 240.578 555.068 236.442 533.5 245.499Z"
fill={palette.neckShadowColor}
/>
</g>
{/* Normal face — eyes, cheeks, mouth.
Wrapped in a rotation group for the thinking head-tilt. */}
{face === 'normal' && (
<g transform={`rotate(${headTilt}, 495, 375)`}>
{/* Sleep eyes — curved closed-lid arcs, visible only when eyeScale ≈ 0 */}
{showSleepEyes && (
<>
<path
d="M390,466 Q411,481 436,466"
stroke="#1C170B"
strokeWidth="5.5"
strokeLinecap="round"
fill="none"
/>
<path
d="M563,466 Q589,481 615,466"
stroke="#1C170B"
strokeWidth="5.5"
strokeLinecap="round"
fill="none"
/>
</>
)}
{/* Left eye — scaleY collapses on blink/sleep; translate shifts gaze while thinking */}
{!showSleepEyes && (
<g transform={`translate(${thinkEyeX}, ${thinkEyeY})`}>
<g transform={`translate(411, 465) scale(1, ${eyeScale}) translate(-411, -465)`}>
<path
d="M411.48 428C419.679 428 423 432 424.408 434.321C431.456 442.807 434.448 450.812 435.286 461.939C436.531 478.451 428.581 501.025 409.176 501.922C402.907 502.212 396.783 499.978 392.177 495.714C372.967 478.168 379.456 428.811 411.48 428Z"
fill="#1C170B"
/>
{!effectiveInBlink && (
<>
<g filter={`url(#${p('f6')})`}>
<path
d="M402.589 435.31C405.113 435.115 406.119 435.015 408.226 436.218C409.449 437.699 409.295 438.305 409.367 440.116C410.18 440.625 410.898 441.111 411.694 441.647L411.904 442.956C419.014 456.194 406.034 468.295 397.004 457.028C387.109 457.791 393.027 445.603 396.045 441.344C398.038 438.531 399.869 437.302 402.589 435.31Z"
fill="#FAF3EC"
/>
</g>
<g filter={`url(#${p('f7')})`}>
<path
d="M402.405 435.12C405.005 434.923 406.041 434.822 408.211 436.033C409.471 437.522 409.312 438.132 409.386 439.954C410.224 440.465 410.964 440.954 411.784 441.493L412 442.811C408.557 441.118 406.625 439.187 402.54 440.654C395.773 443.086 394.268 451.112 396.652 456.966C386.459 457.733 392.555 445.473 395.664 441.189C397.717 438.36 399.602 437.123 402.405 435.12Z"
fill="#3A372F"
/>
</g>
</>
)}
</g>
</g>
)}
{/* Right eye — same blink / sleep; translate shifts gaze while thinking */}
{!showSleepEyes && (
<g transform={`translate(${thinkEyeX}, ${thinkEyeY})`}>
<g transform={`translate(589, 465) scale(1, ${eyeScale}) translate(-589, -465)`}>
<path
d="M589.37 428.706C621.867 428.523 630.994 493.598 594.352 502.663C555.686 504.419 554.456 433.119 589.37 428.706Z"
fill="#1C170B"
/>
{!effectiveInBlink && (
<>
<g filter={`url(#${p('f8')})`}>
<path
d="M576.491 452.759C577.097 454.049 577.14 454.759 576.609 455.979C569.334 454.164 573.452 439.586 580.007 437.664C584.2 436.436 587.824 438.013 589.306 442.115C592.619 444.137 594.847 446.01 595.749 450.049C596.355 452.791 595.845 455.661 594.331 458.027C589.038 466.354 580.303 462.46 578.515 452.619C577.656 451.775 577.93 451.624 577.758 450.079L577.591 450.499L577.887 450.8L577.387 452.615L576.491 452.759Z"
fill="#FAF3EC"
/>
</g>
<g filter={`url(#${p('f9')})`}>
<path
d="M576.06 452.732C576.72 454.041 576.766 454.762 576.188 456C568.275 454.158 572.754 439.363 579.885 437.413C584.446 436.166 588.388 437.766 590 441.93L585.246 442.04C580.159 445.421 579.418 446.592 578.261 452.59C577.327 451.734 577.625 451.58 577.438 450.013L577.257 450.438L577.578 450.743L577.035 452.586L576.06 452.732Z"
fill="#312E24"
/>
</g>
<g filter={`url(#${p('f10')})`}>
<path
d="M576.49 452.759L575.948 452.886L575.475 452.235C575.11 444.84 575.121 438.674 584.935 442.224C580.259 445.556 579.577 446.709 578.514 452.619C577.655 451.776 577.929 451.624 577.757 450.08L577.591 450.499L577.886 450.8L577.387 452.615L576.49 452.759Z"
fill="#534639"
/>
</g>
</>
)}
</g>
</g>
)}
{/* Left cheek */}
<path
d="M354.002 488.785C366.292 488.07 381.734 490.477 385.001 505.019C386.026 509.579 385.143 514.363 382.556 518.257C378.409 524.432 372.217 526.795 365.337 528.245C353.923 529.158 338.873 527.064 334.774 514.24C333.375 509.718 333.887 504.821 336.192 500.686C339.888 493.968 346.962 490.735 354.002 488.785Z"
fill="#F9A6A0"
/>
<g filter={`url(#${p('f11')})`}>
<path
d="M368 494C373.244 494.048 380.363 498.673 380 504C375.832 504.091 367.526 498.087 368 494Z"
fill="#FDC3BF"
/>
</g>
{/* Right cheek */}
<path
d="M626.146 494.285C641.877 485.407 671.147 495.187 664.86 516.522C657.951 539.968 605.954 533.98 615.075 505.471C615.73 503.36 618.571 499.408 620.251 497.867C621.588 496.68 624.466 495.224 626.146 494.285Z"
fill="#EF928B"
/>
<g filter={`url(#${p('f12')})`}>
<path
d="M632.013 497C626.77 497.048 619.65 501.673 620.013 507C624.181 507.091 632.487 501.087 632.013 497Z"
fill="#FDC3BF"
/>
</g>
{/* Mouth — normal smile fades to a concerned "hmm" when thinking */}
{!talking && (
<>
{/* Normal closed smile — fades out as thinking kicks in */}
<g opacity={thinking ? Math.max(0, 1 - thinkProgress * 2.2) : 1}>
<path
d="M471.504 494.784C471.5 491.499 475 489 478.416 490.134C480.5 491.5 480.95 493.63 482.461 495.842C489.371 505.97 498.06 507.141 509.126 502.936C514.767 498.973 514.929 497.593 518.612 491.664C528.419 484.735 532.464 504.579 511.184 513.085C503.114 516.238 494.124 516.055 486.187 512.586C478.627 509.187 473.047 503.065 471.504 494.784Z"
fill="#1C170B"
/>
<path
d="M509.127 502.936C514.767 498.973 514.929 497.593 518.612 491.664L520.234 492.572C521.198 496.986 512.309 506.706 507.958 505.884L507.711 505.234L509.127 502.936Z"
fill="#312E24"
/>
</g>
{/* Thinking / "hmm" mouth — asymmetric slight frown, fades in */}
{thinking && (
<path
d="M480,509 Q490,518 503,511 Q512,505 519,509"
stroke="#1C170B"
strokeWidth="4.5"
strokeLinecap="round"
fill="none"
opacity={Math.min(1, thinkProgress * 2.5)}
/>
)}
</>
)}
{/* Talking mouth — pivot at top edge (y=508).
Whole group scales downward so mouth opens like a jaw drop.
Tongue is sized to stay within mouth walls at all mouthOpen values:
at cx=495 cy=532 rx=24, the widest point (y=532) sits inside the
~73px-wide mouth cavity, with ≥8px margin on each side. */}
{talking && (
<g transform={`translate(495,508) scale(1,${mouthOpen}) translate(-495,-508)`}>
{/* Outer mouth: wide rounded top, deep U-curve bottom */}
<path
d="M453,508 Q453,501 463,501 L527,501 Q537,501 537,508 Q537,532 520,546 Q495,557 470,546 Q453,532 453,508 Z"
fill="#1C170B"
/>
{/* Tongue — centered, safely inside mouth at full open.
Fades in so it's invisible while mouth is nearly closed. */}
<ellipse
cx={495}
cy={532}
rx={24}
ry={10}
fill="#C03030"
opacity={tongueOpacity}
/>
{/* Specular highlight on tongue */}
<ellipse
cx={483}
cy={526}
rx={7}
ry={4}
fill="#E07070"
opacity={tongueOpacity * 0.85}
/>
</g>
)}
</g>
)}
{/* Recording face — pulsing dot, centered at (495, 495): 25px lower + 70% scale.
Transform: place at target center → scale → undo RecordingFace's own offset (520,555). */}
{face === 'recording' && (
<g transform="translate(495, 495) scale(0.7) translate(-520, -555)">
<RecordingFace frame={frame} fps={fps} color={recordingColor} />
</g>
)}
{/* Loading face — spinning ring, same center/scale as recording dot (495, 495, 70%). */}
{face === 'loading' && (
<g transform="translate(495, 495) scale(0.7) translate(-520, -555)">
<LoadingFace frame={frame} fps={fps} color={loadingColor} />
</g>
)}
{/* Zzz — floating letters that drift up after mascot falls asleep */}
{isAsleep && (
<>
<text
x={z1.x}
y={z1.y}
fontSize={z1.fontSize}
fontFamily="Arial Rounded MT Bold, Arial Black, Arial, sans-serif"
fontWeight="900"
fill="#5B9BD5"
opacity={z1.opacity}
textAnchor="middle">
Z
</text>
<text
x={z2.x}
y={z2.y}
fontSize={z2.fontSize}
fontFamily="Arial Rounded MT Bold, Arial Black, Arial, sans-serif"
fontWeight="900"
fill="#4A8AC4"
opacity={z2.opacity}
textAnchor="middle">
Z
</text>
<text
x={z3.x}
y={z3.y}
fontSize={z3.fontSize}
fontFamily="Arial Rounded MT Bold, Arial Black, Arial, sans-serif"
fontWeight="900"
fill="#3A7AB3"
opacity={z3.opacity}
textAnchor="middle">
Z
</text>
</>
)}
</g>
</svg>
</AbsoluteFill>
);
};
@@ -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<YellowMascotIdleProps> = props => (
<MascotCharacter {...props} arm="steady" idPrefix="mascot-idle" />
);
@@ -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<YellowMascotTalkingProps> = props => (
<MascotCharacter {...props} arm="steady" face="normal" talking={true} idPrefix="mascot-talking" />
);
@@ -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<typeof yellowMascotThinkingSchema>;
// 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<YellowMascotThinkingProps> = 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 (
<MascotCharacter
{...props}
arm="steady"
face="normal"
talking={false}
sleeping={false}
thinking={true}
idPrefix="mascot-thinking"
thinkInStartSec={thinkInStartSec}
thinkInEndSec={thinkInEndSec}
thinkOutStartSec={thinkOutStartSec}
thinkOutEndSec={thinkOutEndSec}
/>
);
};
@@ -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 (
<g>
{/* Outer glow halo — expands and fades as the pulse rises. */}
<circle
cx={520}
cy={555}
r={baseR + 20 + pulse * 110}
fill={color}
opacity={0.22 * (1 - pulse)}
/>
<circle
cx={520}
cy={555}
r={baseR + 10 + pulse * 55}
fill={color}
opacity={0.35 * (1 - pulse * 0.8)}
/>
{/* Solid red dot. */}
<circle cx={520} cy={555} r={dotR} fill={color} />
{/* Specular highlight. */}
<ellipse cx={465} cy={495} rx={50} ry={28} fill="#ffffff" opacity={0.22} />
</g>
);
};
@@ -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<number, RAFCallback>();
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<typeof mockRequestAnimationFrame>;
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<typeof useVideoConfig> | null = null;
const Probe = () => {
captured = useVideoConfig();
return null;
};
render(
<FrameProvider fps={30} width={500} height={500} durationInFrames={180}>
<Probe />
</FrameProvider>
);
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(
<FrameProvider fps={30} width={100} height={100} durationInFrames={180}>
<Probe />
</FrameProvider>
);
// 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(
<FrameProvider fps={30} width={100} height={100} durationInFrames={60}>
<Probe />
</FrameProvider>
);
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(<Probe />)).toThrow(/useVideoConfig/);
errSpy.mockRestore();
});
});
@@ -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<number>(0);
export const FrameConfigContext = createContext<FrameConfig | null>(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 <FrameProvider>');
}
return cfg;
};
interface FrameProviderProps extends FrameConfig {
children: ReactNode;
}
export const FrameProvider: FC<FrameProviderProps> = ({
fps,
width,
height,
durationInFrames,
children,
}) => {
const [frame, setFrame] = useState(0);
const startRef = useRef<number | null>(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<FrameConfig>(
() => ({ fps, width, height, durationInFrames }),
[fps, width, height, durationInFrames]
);
return (
<FrameConfigContext.Provider value={config}>
<FrameContext.Provider value={frame}>{children}</FrameContext.Provider>
</FrameConfigContext.Provider>
);
};
/**
* 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<FrameProviderProps> = ({
fps,
width,
height,
durationInFrames,
children,
}) => {
const config = useMemo<FrameConfig>(
() => ({ fps, width, height, durationInFrames }),
[fps, width, height, durationInFrames]
);
return (
<FrameConfigContext.Provider value={config}>
<FrameContext.Provider value={0}>{children}</FrameContext.Provider>
</FrameConfigContext.Provider>
);
};
@@ -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<typeof import('./Mascot')>();
return {
...actual,
RiveMascot: ({ face }: { face?: string }) => <div data-testid="rive-mascot" data-face={face} />,
};
});
function subagentEntry(overrides: Partial<ToolTimelineEntry> = {}): ToolTimelineEntry {
return {
id: 'thread-1:subagent:sub-1:researcher',
+3 -9
View File
@@ -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<SubMascotLayerProps> = ({ entries }) => {
model.status === 'running' ? 'opacity-100' : 'opacity-75',
].join(' ')}>
<div className="drop-shadow-[0_6px_12px_rgba(15,23,42,0.18)]">
<YellowMascot size="100%" mascotColor={model.color} face={model.face} static />
<RiveMascot size="100%" face={model.face} />
</div>
</div>
<div
+56 -361
View File
@@ -1,73 +1,13 @@
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { type FC, useEffect, useMemo, useRef, useState } from 'react';
import { type FC, useCallback, useEffect, useRef, useState } from 'react';
import { useAppSelector } from '../../store/hooks';
import { selectMascotColor } from '../../store/mascotSlice';
import {
type FrameConfig,
FrameConfigContext,
FrameContext,
} from '../human/Mascot/yellow/frameContext';
import { YellowMascotIdle } from '../human/Mascot/yellow/MascotIdle';
import { RiveMascot } from '../human/Mascot';
/**
* Meet camera frame producer.
*
* Mounted once at app root. Listens for the shell-emitted
* `meet-video:bus-started` / `meet-video:bus-stopped` events and, while
* a session is active, renders a hidden Remotion-driven mascot,
* rasterizes its SVG to a 640×480 JPEG every frame, and pushes the
* bytes over a loopback WebSocket to the Rust frame bus
* (`app/src-tauri/src/meet_video/frame_bus.rs`). The Rust side fans
* each frame out to the consumer — the camera bridge inside the Meet
* CEF webview, which paints them onto its capture canvas
* (`canvas.captureStream(30)` → `getUserMedia` intercept).
*
* ## Why the mascot lives here, not in the Meet webview
*
* `CLAUDE.md` rules out growing JS injection into CEF child webviews.
* The Remotion runtime + composition tree is too large to inject and
* would run inside a third-party origin sandbox; that's a non-starter.
* Instead the rich animation lives in our own renderer (where Remotion
* is already a project dependency) and we ship its pixels — not its
* code — to the Meet origin.
*
* ## Why XMLSerializer instead of `@remotion/player`
*
* Remotion's `<Player>` 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 → `<img>` 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<HTMLDivElement>(null);
const mascotColor = useAppSelector(selectMascotColor);
const wsRef = useRef<WebSocket | null>(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 `<FrameProvider>` 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<number | null>(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 <audio> ─────
// Chromium throttles main-thread setInterval *and* worker timers
// when the page is backgrounded / not the key window. A page
// that's "playing audio" (incl. silent muted audio) is exempt.
//
// We tried `AudioContext` first; that fails because Chromium's
// autoplay policy starts the context in `suspended` state and
// `resume()` only succeeds inside a user-gesture handler — which
// never happens for the auto-launched dev meet call. Symptom:
// pipeline ran at 24fps for ~20s, then collapsed to 1fps as soon
// as the renderer's "playing audio" grace period expired.
//
// `<audio muted>` is exempt from the autoplay policy and *does*
// start playing without a gesture, putting the page in the
// "playing media" state Chromium uses to gate background
// throttling. The base64'd silent WAV is ~70 bytes; loop=true
// keeps it perpetually "playing" without ever needing a fetch.
const SILENT_WAV =
'data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEAQB8AAEAfAAABAAgAAABmYWN0BAAAAAAAAABkYXRhAAAAAA==';
const keepAliveAudio = document.createElement('audio');
@@ -163,14 +123,10 @@ const ProducerSession: FC<{ session: BusSession }> = ({ session }) => {
keepAliveAudio.src = SILENT_WAV;
keepAliveAudio.style.display = 'none';
document.body.appendChild(keepAliveAudio);
// Trigger play() explicitly — autoplay attribute alone is racy in
// some Chromium builds; play() returns a promise that resolves
// once the media is actually playing.
void keepAliveAudio
.play()
.catch(err => console.warn('[meet-video-producer] silent audio play() failed', err));
// ── WS connect ─────────────────────────────────────────────────────
const url = `ws://127.0.0.1:${session.port}`;
let ws: WebSocket;
try {
@@ -193,37 +149,7 @@ const ProducerSession: FC<{ session: BusSession }> = ({ session }) => {
console.warn('[meet-video-producer] ws error', err);
};
// ── Per-frame rasterize + push loop ───────────────────────────────
// Reused across ticks. The OffscreenCanvas keeps the JPEG encode off
// the main DOM canvas pipeline.
const offscreen =
typeof OffscreenCanvas !== 'undefined'
? new OffscreenCanvas(FRAME_W, FRAME_H)
: (() => {
const c = document.createElement('canvas');
c.width = FRAME_W;
c.height = FRAME_H;
return c as unknown as OffscreenCanvas;
})();
const ctx = (offscreen as unknown as OffscreenCanvas).getContext(
'2d'
) as OffscreenCanvasRenderingContext2D | null;
if (!ctx) {
console.warn('[meet-video-producer] no 2d ctx — aborting');
return;
}
const serializer = typeof XMLSerializer !== 'undefined' ? new XMLSerializer() : null;
const intervalMs = Math.round(1000 / PRODUCER_FPS);
// Heartbeat from a Web Worker, NOT main-thread setInterval.
// Background-throttling: when the meet window has focus, the main
// openhuman window is no longer foreground, and Chromium throttles
// main-thread setInterval to ~1Hz. Worker timers run in a separate
// event loop and are throttled much less aggressively, which keeps
// the producer hitting its target rate while the user is looking
// at Meet. Inlined as a Blob URL so we don't need a separate
// worker file in the bundler graph.
const workerSrc =
'let t=null;self.onmessage=(e)=>{const d=e.data||{};' +
"if(d.cmd==='start'){clearInterval(t);t=setInterval(()=>self.postMessage('tick'),d.intervalMs);}" +
@@ -232,90 +158,13 @@ const ProducerSession: FC<{ session: BusSession }> = ({ session }) => {
const workerUrl = URL.createObjectURL(blob);
const worker = new Worker(workerUrl);
// Diagnostic counters. Every 2s we post a JSON snapshot through
// the WS as a text frame; the Rust side logs it as
// `[meet-video-producer-diag]` so we can compare:
// - worker_ticks: how often the worker actually fires (should
// be ~PRODUCER_FPS regardless of focus)
// - encode_started / encode_completed: how many encodes ran;
// gap → encode is the bottleneck, not timer throttling
// - encode_avg_ms: per-frame encode cost
// - inflight_skips: how many ticks were dropped because a
// prior encode was still running
let workerTicks = 0;
let encodeStarted = 0;
let encodeCompleted = 0;
let encodeMsTotal = 0;
let inflightSkips = 0;
const diagInterval = window.setInterval(() => {
try {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const payload = JSON.stringify({
source: 'producer',
worker_ticks: workerTicks,
encode_started: encodeStarted,
encode_completed: encodeCompleted,
encode_avg_ms: encodeCompleted > 0 ? Math.round(encodeMsTotal / encodeCompleted) : 0,
inflight_skips: inflightSkips,
ws_state: ws.readyState,
frame: frameAtTickRef.current,
});
ws.send(payload);
} catch (_) {
// diagnostics best-effort; swallow to avoid breaking the worker tick.
}
workerTicks = 0;
encodeStarted = 0;
encodeCompleted = 0;
encodeMsTotal = 0;
inflightSkips = 0;
}, 2000);
const onTick = () => {
workerTicks++;
// Always advance the React frame so the mascot keeps animating
// even before the WS is ready and even when the main window is
// backgrounded. Computed from Date.now() so we're robust to the
// worker setInterval drifting under throttling.
if (startTimeRef.current === null) startTimeRef.current = Date.now();
const elapsedMs = Date.now() - startTimeRef.current;
const nextFrame = Math.floor((elapsedMs / 1000) * PRODUCER_FPS) % MASCOT_LOOP_FRAMES;
setFrame(prev => (prev === nextFrame ? prev : nextFrame));
frameAtTickRef.current = nextFrame;
if (stoppedRef.current || !wsReadyRef.current) return;
// Drop frames if a previous encode is still inflight rather than
// letting them queue up unbounded.
if (inflightRef.current) {
inflightSkips++;
return;
}
const host = hostRef.current;
if (!host || !serializer) return;
const svg = host.querySelector('svg');
if (!svg) return;
inflightRef.current = true;
encodeStarted++;
const startedAt = window.performance.now();
void encodeAndSend(svg, serializer, ctx, ws)
.then(ok => {
if (ok) {
sentFramesRef.current++;
encodeCompleted++;
encodeMsTotal += window.performance.now() - startedAt;
}
})
.finally(() => {
inflightRef.current = false;
});
worker.onmessage = () => {
void captureFrame();
};
worker.onmessage = onTick;
worker.postMessage({ cmd: 'start', intervalMs });
return () => {
stoppedRef.current = true;
window.clearInterval(diagInterval);
try {
worker.postMessage({ cmd: 'stop' });
worker.terminate();
@@ -337,27 +186,8 @@ const ProducerSession: FC<{ session: BusSession }> = ({ session }) => {
wsRef.current = null;
wsReadyRef.current = false;
};
}, [session.port]);
}, [session.port, captureFrame]);
const frameConfig = useMemo<FrameConfig>(
() => ({
fps: PRODUCER_FPS,
width: MASCOT_CANVAS,
height: MASCOT_CANVAS,
durationInFrames: MASCOT_LOOP_FRAMES,
}),
[]
);
// The mascot host lives off-screen but in the layout tree so the SVG
// gets laid out + animated normally. Fixed pixel size so the SVG
// serialization renders at a predictable resolution.
//
// We bypass the shared `<YellowMascot>` wrapper because it
// re-establishes its own rAF-based FrameProvider — which freezes
// when the main window is backgrounded (see comment on the `frame`
// state above). Rendering `YellowMascotIdle` directly inside our own
// worker-driven contexts keeps the animation alive.
return (
<div
ref={hostRef}
@@ -371,144 +201,9 @@ const ProducerSession: FC<{ session: BusSession }> = ({ session }) => {
pointerEvents: 'none',
opacity: 0,
}}>
<style>{`.mascot-producer-host svg { width: 100% !important; height: 100% !important; }`}</style>
<div className="mascot-producer-host" style={{ width: '100%', height: '100%' }}>
<FrameConfigContext.Provider value={frameConfig}>
<FrameContext.Provider value={frame}>
<YellowMascotIdle
face="normal"
recordingColor="#ff3b30"
loadingColor="#ffffff"
greeting={false}
sleeping={false}
mascotColor={mascotColor}
arm="wave"
talking={false}
thinking={false}
/>
</FrameContext.Provider>
</FrameConfigContext.Provider>
</div>
<RiveMascot face="idle" size={FRAME_H} />
</div>
);
};
async function encodeAndSend(
svg: SVGElement,
serializer: XMLSerializer,
ctx: OffscreenCanvasRenderingContext2D,
ws: WebSocket
): Promise<boolean> {
try {
// Make sure the SVG carries width/height/xmlns so the standalone
// data URI parses on its own (it's pulled out of the React tree).
const clone = svg.cloneNode(true) as SVGElement;
if (!clone.hasAttribute('xmlns')) {
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
}
// Force the SVG to render at our target resolution so the
// rasterizer doesn't waste work painting a 1000×1000 surface
// we'd downscale anyway.
clone.setAttribute('width', `${FRAME_H}`);
clone.setAttribute('height', `${FRAME_H}`);
const xml = serializer.serializeToString(clone);
// `createImageBitmap(Blob)` is significantly faster than
// `<img>.decode()` in Chromium: it dispatches to the rasterizer
// worker pool and skips the data-URI percent-encode roundtrip
// (a 3050 KB SVG was getting URL-escaped → main-thread parsed
// every frame, which dominated the per-frame budget).
const svgBlob = new Blob([xml], { type: 'image/svg+xml' });
let bitmap: ImageBitmap;
try {
bitmap = await window.createImageBitmap(svgBlob);
} catch (_err) {
// Some Chromium builds reject SVG blobs in createImageBitmap;
// fall back to the <img> decode path.
const url = URL.createObjectURL(svgBlob);
try {
const img = new window.Image();
img.decoding = 'async';
img.src = url;
await img.decode();
ctx.fillStyle = '#F7F4EE';
ctx.fillRect(0, 0, FRAME_W, FRAME_H);
ctx.drawImage(img, 0, 0, FRAME_W, FRAME_H);
// (skip the rest of the gradient/inset path on the fallback
// — it's only used when createImageBitmap fails, which is
// rare; the encode block below handles JPEG conversion.)
} finally {
URL.revokeObjectURL(url);
}
// Do the JPEG encode + send and return early.
const oc = ctx.canvas as OffscreenCanvas;
const blob =
'convertToBlob' in oc
? await oc.convertToBlob({ type: 'image/jpeg', quality: JPEG_QUALITY })
: await new Promise<Blob>((resolve, reject) => {
(ctx.canvas as unknown as HTMLCanvasElement).toBlob(
b => (b ? resolve(b) : reject(new Error('toBlob null'))),
'image/jpeg',
JPEG_QUALITY
);
});
const buffer = await blob.arrayBuffer();
if (ws.readyState === WebSocket.OPEN) {
ws.send(buffer);
return true;
}
return false;
}
// Subtle off-yellow radial gradient — warmer center, slightly
// darker edges. Premium-feeling backdrop without being noisy.
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'); // warm cream highlight
grad.addColorStop(1, '#EFE3B8'); // soft butter edge
ctx.fillStyle = grad;
ctx.fillRect(0, 0, FRAME_W, FRAME_H);
// Contain-fit (with a small inset) so the *whole* mascot lands in
// the frame.
const inset = 0.06; // 6% breathing room on the short axis
const fitW = FRAME_W * (1 - 2 * inset);
const fitH = FRAME_H * (1 - 2 * inset);
const scale = Math.min(fitW / bitmap.width, fitH / bitmap.height);
const dw = bitmap.width * scale;
const dh = bitmap.height * scale;
const dx = (FRAME_W - dw) / 2;
const dy = (FRAME_H - dh) / 2;
ctx.drawImage(bitmap, dx, dy, dw, dh);
bitmap.close();
const oc = ctx.canvas as OffscreenCanvas;
const blob =
'convertToBlob' in oc
? await oc.convertToBlob({ type: 'image/jpeg', quality: JPEG_QUALITY })
: await new Promise<Blob>((resolve, reject) => {
(ctx.canvas as unknown as HTMLCanvasElement).toBlob(
b => (b ? resolve(b) : reject(new Error('toBlob null'))),
'image/jpeg',
JPEG_QUALITY
);
});
const buffer = await blob.arrayBuffer();
if (ws.readyState === WebSocket.OPEN) {
ws.send(buffer);
return true;
}
return false;
} catch (err) {
console.warn('[meet-video-producer] encode/send failed', err);
return false;
}
}
export default MascotFrameProducer;
+3 -1
View File
@@ -477,8 +477,10 @@ const ar5: TranslationMap = {
'settings.mascot.colorAria': 'OpenHuman اللون',
'settings.mascot.colorBlack': 'أسود',
'settings.mascot.colorBurgundy': 'عنابي',
'settings.mascot.colorGreen': 'أخضر',
'settings.mascot.colorCustom': 'Custom',
'settings.mascot.colorNavy': 'بحري',
'settings.mascot.primaryColor': 'Primary color',
'settings.mascot.secondaryColor': 'Secondary color',
'settings.mascot.colorYellow': 'أصفر',
'settings.mascot.libraryUnavailable': 'مكتبة OpenHuman غير متاحة',
'settings.mascot.title': 'OpenHuman',
+3 -1
View File
@@ -483,8 +483,10 @@ const bn5: TranslationMap = {
'settings.mascot.colorAria': 'OpenHuman রঙ',
'settings.mascot.colorBlack': 'কালো',
'settings.mascot.colorBurgundy': 'বারগান্ডি',
'settings.mascot.colorGreen': 'সবুজ',
'settings.mascot.colorCustom': 'Custom',
'settings.mascot.colorNavy': 'নৌবাহিনী',
'settings.mascot.primaryColor': 'Primary color',
'settings.mascot.secondaryColor': 'Secondary color',
'settings.mascot.colorYellow': 'হলুদ',
'settings.mascot.libraryUnavailable': 'OpenHuman লাইব্রেরি অনুপলব্ধ',
'settings.mascot.title': 'OpenHuman',
+3 -1
View File
@@ -509,8 +509,10 @@ const de5: TranslationMap = {
'settings.mascot.colorAria': 'OpenHuman Farbe',
'settings.mascot.colorBlack': 'Schwarz',
'settings.mascot.colorBurgundy': 'Burgund',
'settings.mascot.colorGreen': 'Grün',
'settings.mascot.colorCustom': 'Custom',
'settings.mascot.colorNavy': 'Marine',
'settings.mascot.primaryColor': 'Primary color',
'settings.mascot.secondaryColor': 'Secondary color',
'settings.mascot.colorYellow': 'Gelb',
'settings.mascot.libraryUnavailable': 'OpenHuman Bibliothek nicht verfügbar',
'settings.mascot.title': 'OpenHuman',
+3 -1
View File
@@ -489,8 +489,10 @@ const en5: TranslationMap = {
'settings.mascot.colorAria': 'OpenHuman color',
'settings.mascot.colorBlack': 'Black',
'settings.mascot.colorBurgundy': 'Burgundy',
'settings.mascot.colorGreen': 'Green',
'settings.mascot.colorCustom': 'Custom',
'settings.mascot.colorNavy': 'Navy',
'settings.mascot.primaryColor': 'Primary color',
'settings.mascot.secondaryColor': 'Secondary color',
'settings.mascot.colorYellow': 'Yellow',
'settings.mascot.libraryUnavailable': 'OpenHuman library unavailable',
'settings.mascot.title': 'OpenHuman',
+3 -1
View File
@@ -490,8 +490,10 @@ const es5: TranslationMap = {
'settings.mascot.colorAria': 'OpenHuman color',
'settings.mascot.colorBlack': 'negro',
'settings.mascot.colorBurgundy': 'Borgoña',
'settings.mascot.colorGreen': 'Verde',
'settings.mascot.colorCustom': 'Custom',
'settings.mascot.colorNavy': 'Marina',
'settings.mascot.primaryColor': 'Primary color',
'settings.mascot.secondaryColor': 'Secondary color',
'settings.mascot.colorYellow': 'amarillo',
'settings.mascot.libraryUnavailable': 'Biblioteca OpenHuman no disponible',
'settings.mascot.title': 'OpenHuman',
+3 -1
View File
@@ -494,8 +494,10 @@ const fr5: TranslationMap = {
'settings.mascot.colorAria': 'OpenHuman couleur',
'settings.mascot.colorBlack': 'Noir',
'settings.mascot.colorBurgundy': 'Bordeaux',
'settings.mascot.colorGreen': 'Vert',
'settings.mascot.colorCustom': 'Custom',
'settings.mascot.colorNavy': 'Marine',
'settings.mascot.primaryColor': 'Primary color',
'settings.mascot.secondaryColor': 'Secondary color',
'settings.mascot.colorYellow': 'Jaune',
'settings.mascot.libraryUnavailable': 'OpenHuman bibliothèque indisponible',
'settings.mascot.title': 'OpenHuman',
+3 -1
View File
@@ -485,8 +485,10 @@ const hi5: TranslationMap = {
'settings.mascot.colorAria': 'OpenHuman रंग',
'settings.mascot.colorBlack': 'काला',
'settings.mascot.colorBurgundy': 'बरगंडी',
'settings.mascot.colorGreen': 'हरा',
'settings.mascot.colorCustom': 'Custom',
'settings.mascot.colorNavy': 'नौसेना',
'settings.mascot.primaryColor': 'Primary color',
'settings.mascot.secondaryColor': 'Secondary color',
'settings.mascot.colorYellow': 'पीला',
'settings.mascot.libraryUnavailable': 'OpenHuman लाइब्रेरी अनुपलब्ध है',
'settings.mascot.title': 'OpenHuman',
+3 -1
View File
@@ -485,8 +485,10 @@ const id5: TranslationMap = {
'settings.mascot.colorAria': 'Warna OpenHuman',
'settings.mascot.colorBlack': 'Hitam',
'settings.mascot.colorBurgundy': 'Burgundi',
'settings.mascot.colorGreen': 'Hijau',
'settings.mascot.colorCustom': 'Custom',
'settings.mascot.colorNavy': 'Biru tua',
'settings.mascot.primaryColor': 'Primary color',
'settings.mascot.secondaryColor': 'Secondary color',
'settings.mascot.colorYellow': 'Kuning',
'settings.mascot.libraryUnavailable': 'Library OpenHuman tidak tersedia',
'settings.mascot.title': 'OpenHuman',
+3 -1
View File
@@ -490,8 +490,10 @@ const it5: TranslationMap = {
'settings.mascot.colorAria': 'OpenHuman colore',
'settings.mascot.colorBlack': 'Nero',
'settings.mascot.colorBurgundy': 'Borgogna',
'settings.mascot.colorGreen': 'Verde',
'settings.mascot.colorCustom': 'Custom',
'settings.mascot.colorNavy': 'Blu marino',
'settings.mascot.primaryColor': 'Primary color',
'settings.mascot.secondaryColor': 'Secondary color',
'settings.mascot.colorYellow': 'Giallo',
'settings.mascot.libraryUnavailable': 'OpenHuman libreria non disponibile',
'settings.mascot.title': 'OpenHuman',
+3 -1
View File
@@ -445,8 +445,10 @@ const ko5: TranslationMap = {
'settings.mascot.colorAria': 'OpenHuman 색상',
'settings.mascot.colorBlack': '검정',
'settings.mascot.colorBurgundy': '버건디',
'settings.mascot.colorGreen': '초록',
'settings.mascot.colorCustom': 'Custom',
'settings.mascot.colorNavy': '네이비',
'settings.mascot.primaryColor': 'Primary color',
'settings.mascot.secondaryColor': 'Secondary color',
'settings.mascot.colorYellow': '노랑',
'settings.mascot.libraryUnavailable': 'OpenHuman 라이브러리를 사용할 수 없음',
'settings.mascot.title': 'OpenHuman',
+3 -1
View File
@@ -491,8 +491,10 @@ const pt5: TranslationMap = {
'settings.mascot.colorAria': 'OpenHuman cor',
'settings.mascot.colorBlack': 'Preto',
'settings.mascot.colorBurgundy': 'Borgonha',
'settings.mascot.colorGreen': 'Verde',
'settings.mascot.colorCustom': 'Custom',
'settings.mascot.colorNavy': 'Marinho',
'settings.mascot.primaryColor': 'Primary color',
'settings.mascot.secondaryColor': 'Secondary color',
'settings.mascot.colorYellow': 'Amarelo',
'settings.mascot.libraryUnavailable': 'OpenHuman biblioteca indisponível',
'settings.mascot.title': 'OpenHuman',
+3 -1
View File
@@ -487,8 +487,10 @@ const ru5: TranslationMap = {
'settings.mascot.colorAria': 'OpenHuman цвет',
'settings.mascot.colorBlack': 'Черный',
'settings.mascot.colorBurgundy': 'Бордовый',
'settings.mascot.colorGreen': 'Зеленый',
'settings.mascot.colorCustom': 'Custom',
'settings.mascot.colorNavy': 'Темно-синий',
'settings.mascot.primaryColor': 'Primary color',
'settings.mascot.secondaryColor': 'Secondary color',
'settings.mascot.colorYellow': 'Желтый',
'settings.mascot.libraryUnavailable': 'OpenHuman библиотека недоступна',
'settings.mascot.title': 'OpenHuman',
+3 -1
View File
@@ -456,8 +456,10 @@ const zhCN5: TranslationMap = {
'settings.mascot.colorAria': 'OpenHuman 颜色',
'settings.mascot.colorBlack': '黑色',
'settings.mascot.colorBurgundy': '酒红色',
'settings.mascot.colorGreen': '绿色',
'settings.mascot.colorCustom': 'Custom',
'settings.mascot.colorNavy': '深蓝色',
'settings.mascot.primaryColor': 'Primary color',
'settings.mascot.secondaryColor': 'Secondary color',
'settings.mascot.colorYellow': '黄色',
'settings.mascot.libraryUnavailable': 'OpenHuman 资源库不可用',
'settings.mascot.title': 'OpenHuman',
+3 -1
View File
@@ -3038,8 +3038,10 @@ const en: TranslationMap = {
'settings.mascot.colorHeading': 'Color',
'settings.mascot.colorBlack': 'Black',
'settings.mascot.colorBurgundy': 'Burgundy',
'settings.mascot.colorGreen': 'Green',
'settings.mascot.colorCustom': 'Custom',
'settings.mascot.colorNavy': 'Navy',
'settings.mascot.primaryColor': 'Primary color',
'settings.mascot.secondaryColor': 'Secondary color',
'settings.mascot.colorYellow': 'Yellow',
'settings.mascot.libraryUnavailable': 'OpenHuman library unavailable',
'settings.mascot.title': 'OpenHuman',
+8
View File
@@ -3,6 +3,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MascotWindowApp from './MascotWindowApp';
vi.mock('../features/human/Mascot', async importOriginal => {
const actual = await importOriginal<typeof import('../features/human/Mascot')>();
return {
...actual,
RiveMascot: ({ face }: { face?: string }) => <div data-testid="rive-mascot" data-face={face} />,
};
});
describe('MascotWindowApp', () => {
beforeEach(() => {
vi.useFakeTimers();
+2 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { YellowMascot } from '../features/human/Mascot';
import { RiveMascot } from '../features/human/Mascot';
import type { MascotFace } from '../features/human/Mascot/Ghosty';
/**
@@ -52,7 +52,7 @@ const MascotWindowApp = () => {
return (
<div style={{ position: 'fixed', inset: 0, background: 'transparent' }} data-face={face}>
<YellowMascot face={face} groundShadowOpacity={0.75} compactArmShading />
<RiveMascot face={face} />
</div>
);
};
+2 -5
View File
@@ -47,11 +47,8 @@ vi.mock('../../features/human/useHumanMascot', () => ({
useHumanMascot: vi.fn(() => ({ face: 'idle', viseme: { aa: 0, E: 0, I: 0, O: 0, U: 0 } })),
}));
// Stub YellowMascot to avoid SVG / RAF complexity in tests.
vi.mock('../../features/human/Mascot', () => ({
YellowMascot: ({ face }: { face: string }) => (
<div data-testid="yellow-mascot" data-face={face} />
),
RiveMascot: ({ face }: { face: string }) => <div data-testid="rive-mascot" data-face={face} />,
}));
// PTT plugin mock ─ intercept before any import resolution.
@@ -138,7 +135,7 @@ afterEach(() => {
describe('MascotScreen', () => {
it('renders mascot canvas and input', () => {
renderMascotScreen();
expect(screen.getByTestId('yellow-mascot')).toBeInTheDocument();
expect(screen.getByTestId('rive-mascot')).toBeInTheDocument();
expect(screen.getByPlaceholderText(/type a message/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /send message/i })).toBeInTheDocument();
});
+2 -2
View File
@@ -34,7 +34,7 @@ import {
stopListening,
} from 'tauri-plugin-ptt-api';
import { YellowMascot } from '../../features/human/Mascot';
import { RiveMascot } from '../../features/human/Mascot';
import { useHumanMascot } from '../../features/human/useHumanMascot';
import { useT } from '../../lib/i18n/I18nContext';
import {
@@ -431,7 +431,7 @@ export const MascotScreen: FC = () => {
{/* Mascot canvas */}
<div className="flex-1 flex items-center justify-center overflow-hidden min-h-0 py-4">
<div className="w-full max-w-xs aspect-square">
<YellowMascot face={face} arm="none" size="100%" />
<RiveMascot face={face} />
</div>
</div>
+9 -9
View File
@@ -32,15 +32,15 @@ describe('mascotSlice', () => {
});
it('setMascotColor ignores unknown variants', () => {
const before = reducer(undefined, setMascotColor('green'));
const before = reducer(undefined, setMascotColor('navy'));
// Cast: simulate a stale call (e.g. an older build dispatching a removed
// variant) without weakening the public action signature.
const after = reducer(before, setMascotColor('pink' as unknown as 'green'));
expect(after.color).toBe('green');
const after = reducer(before, setMascotColor('pink' as unknown as 'navy'));
expect(after.color).toBe('navy');
});
it('resetUserScopedState resets back to default', () => {
const dirty = reducer(undefined, setMascotColor('green'));
const dirty = reducer(undefined, setMascotColor('navy'));
const reset = reducer(dirty, resetUserScopedState());
expect(reset.color).toBe(DEFAULT_MASCOT_COLOR);
});
@@ -52,7 +52,7 @@ describe('mascotSlice', () => {
it('exposes all five supported colors', () => {
expect(new Set(SUPPORTED_MASCOT_COLORS)).toEqual(
new Set(['yellow', 'burgundy', 'black', 'navy', 'green'])
new Set(['yellow', 'burgundy', 'black', 'navy', 'custom'])
);
});
@@ -60,9 +60,9 @@ describe('mascotSlice', () => {
const rehydrate = (key: string, payload?: unknown) => ({ type: REHYDRATE, key, payload });
it('ignores REHYDRATE for a different persist key', () => {
const initial = reducer(undefined, setMascotColor('green'));
const initial = reducer(undefined, setMascotColor('navy'));
const state = reducer(initial, rehydrate('other', { color: 'navy' }));
expect(state.color).toBe('green');
expect(state.color).toBe('navy');
});
it('restores a valid persisted color for the mascot key', () => {
@@ -142,8 +142,8 @@ describe('mascotSlice', () => {
// Pre-#1762 blobs only carry `color`; the slice must not throw or
// crash on missing keys — that would brick rehydrate for everyone
// on an upgrade.
const state = reducer(undefined, rehydrate('mascot', { color: 'green' }));
expect(state.color).toBe('green');
const state = reducer(undefined, rehydrate('mascot', { color: 'navy' }));
expect(state.color).toBe('navy');
expect(state.voiceId).toBeNull();
});
});
+27 -1
View File
@@ -15,7 +15,7 @@ export const SUPPORTED_MASCOT_COLORS: readonly MascotColor[] = [
'burgundy',
'black',
'navy',
'green',
'custom',
];
export const DEFAULT_MASCOT_COLOR: MascotColor = 'yellow';
@@ -124,6 +124,8 @@ export interface MascotState {
* override is absent or scrubbed during rehydrate.
*/
customMascotGifUrl: string | null;
customPrimaryColor: string;
customSecondaryColor: string;
}
const initialState: MascotState = {
@@ -133,6 +135,8 @@ const initialState: MascotState = {
voiceUseLocaleDefault: false,
selectedMascotId: null,
customMascotGifUrl: null,
customPrimaryColor: '#F7D145',
customSecondaryColor: '#B23C05',
};
function isMascotColor(value: unknown): value is MascotColor {
@@ -206,6 +210,12 @@ const mascotSlice = createSlice({
setMascotVoiceUseLocaleDefault(state, action: PayloadAction<boolean>) {
state.voiceUseLocaleDefault = Boolean(action.payload);
},
setCustomPrimaryColor(state, action: PayloadAction<string>) {
state.customPrimaryColor = action.payload;
},
setCustomSecondaryColor(state, action: PayloadAction<string>) {
state.customSecondaryColor = action.payload;
},
},
extraReducers: builder => {
builder.addCase(resetUserScopedState, () => initialState);
@@ -222,6 +232,8 @@ const mascotSlice = createSlice({
voiceUseLocaleDefault?: unknown;
selectedMascotId?: unknown;
customMascotGifUrl?: unknown;
customPrimaryColor?: unknown;
customSecondaryColor?: unknown;
};
};
if (rehydrateAction.key !== 'mascot') return;
@@ -261,6 +273,12 @@ const mascotSlice = createSlice({
typeof rehydrateAction.payload?.voiceUseLocaleDefault === 'boolean'
? rehydrateAction.payload.voiceUseLocaleDefault
: false;
const rpc = rehydrateAction.payload?.customPrimaryColor;
state.customPrimaryColor =
typeof rpc === 'string' && rpc.length > 0 ? rpc : initialState.customPrimaryColor;
const rsc = rehydrateAction.payload?.customSecondaryColor;
state.customSecondaryColor =
typeof rsc === 'string' && rsc.length > 0 ? rsc : initialState.customSecondaryColor;
});
},
});
@@ -272,6 +290,8 @@ export const {
setMascotVoiceUseLocaleDefault,
setSelectedMascotId,
setCustomMascotGifUrl,
setCustomPrimaryColor,
setCustomSecondaryColor,
} = mascotSlice.actions;
export const selectMascotColor = (state: { mascot: MascotState }): MascotColor =>
@@ -292,6 +312,12 @@ export const selectSelectedMascotId = (state: { mascot: MascotState }): string |
export const selectCustomMascotGifUrl = (state: { mascot: MascotState }): string | null =>
state.mascot.customMascotGifUrl;
export const selectCustomPrimaryColor = (state: { mascot: MascotState }): string =>
state.mascot.customPrimaryColor;
export const selectCustomSecondaryColor = (state: { mascot: MascotState }): string =>
state.mascot.customSecondaryColor;
/**
* Resolve the voice id the next reply will be synthesised with, taking
* into account every mascot-voice setting plus the active locale. This
+1
View File
@@ -60,6 +60,7 @@
"ws": "^8.20.0"
},
"dependencies": {
"@rive-app/react-canvas": "^4.28.6",
"@tauri-apps/api": "2.10.1"
}
}
+37
View File
@@ -11,6 +11,9 @@ importers:
.:
dependencies:
'@rive-app/react-canvas':
specifier: ^4.28.6
version: 4.28.6(react@19.2.5)
'@tauri-apps/api':
specifier: 2.10.1
version: 2.10.1
@@ -51,6 +54,9 @@ importers:
'@remotion/zod-types':
specifier: 4.0.454
version: 4.0.454(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6)
'@rive-app/react-webgl2':
specifier: ^4.28.6
version: 4.28.6(react@19.2.5)
'@scure/base':
specifier: ^2.2.0
version: 2.2.0
@@ -1361,6 +1367,22 @@ packages:
peerDependencies:
zod: 4.3.6
'@rive-app/canvas@2.37.8':
resolution: {integrity: sha512-nffrPG+VkBKHAxZdcqYlP5M+n/mOAoagj774HH397UPTsfC27gwQURg64i6dX7WswMc5qIVX0rzCrZ86Wb2HOA==}
'@rive-app/react-canvas@4.28.6':
resolution: {integrity: sha512-tMEb7uDr+xuPny4HRVnkfGDHgTCkTFn15u7GHj12hC94N59g/dO15TiGr9312GVTO82FulOymOKy6sgxZyi5rw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0
'@rive-app/react-webgl2@4.28.6':
resolution: {integrity: sha512-QqtlD7bm01SxMLuEHE/BfhiPoX3RZ2/B32nF0IR0IRPe2ykxMeebWPtsX50AL9EKLbOh3Fgk3PJ7smkkvQwD2w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0
'@rive-app/webgl2@2.37.8':
resolution: {integrity: sha512-Y2nXPwAeQtZrADNIzY7v3Lk7XZRMy4Gd+bpGFyzkaQ/EQ91Hp/6sCdd8yTCyjH4b71N/T6kU0MHIYA0IDmpjyQ==}
'@rolldown/binding-android-arm64@1.0.0-rc.17':
resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2036,6 +2058,7 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@vitejs/plugin-react@6.0.1':
resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==}
@@ -6615,6 +6638,20 @@ snapshots:
- react
- react-dom
'@rive-app/canvas@2.37.8': {}
'@rive-app/react-canvas@4.28.6(react@19.2.5)':
dependencies:
'@rive-app/canvas': 2.37.8
react: 19.2.5
'@rive-app/react-webgl2@4.28.6(react@19.2.5)':
dependencies:
'@rive-app/webgl2': 2.37.8
react: 19.2.5
'@rive-app/webgl2@2.37.8': {}
'@rolldown/binding-android-arm64@1.0.0-rc.17':
optional: true
BIN
View File
Binary file not shown.