diff --git a/app/package.json b/app/package.json index a19b042ca6..0af6ef9356 100644 --- a/app/package.json +++ b/app/package.json @@ -75,13 +75,13 @@ "@reduxjs/toolkit": "^2.11.2", "@remotion/player": "4.0.454", "@remotion/zod-types": "4.0.454", + "@rive-app/react-webgl2": "^4.28.6", "@scure/base": "^2.2.0", "@scure/bip32": "^2.0.1", "@scure/bip39": "^2.0.1", "@sentry/react": "^10.38.0", "@tauri-apps/api": "^2.10.0", "@tauri-apps/plugin-barcode-scanner": "^2.4.4", - "tauri-plugin-ptt-api": "workspace:*", "@tauri-apps/plugin-deep-link": "^2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-os": "^2.3.2", @@ -104,6 +104,7 @@ "redux-persist": "^6.0.0", "remotion": "4.0.454", "socket.io-client": "^4.8.3", + "tauri-plugin-ptt-api": "workspace:*", "three": "^0.183.2", "util": "^0.12.5", "zod": "4.3.6" diff --git a/app/public/tiny_mascot.riv b/app/public/tiny_mascot.riv new file mode 100644 index 0000000000..5e259e66fc Binary files /dev/null and b/app/public/tiny_mascot.riv differ diff --git a/app/src/components/settings/panels/MascotPanel.tsx b/app/src/components/settings/panels/MascotPanel.tsx index bb0776a6f9..0184c1a0c3 100644 --- a/app/src/components/settings/panels/MascotPanel.tsx +++ b/app/src/components/settings/panels/MascotPanel.tsx @@ -1,9 +1,13 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; -import { CustomGifMascot } from '../../../features/human/Mascot'; +import { CustomGifMascot, RiveMascot } from '../../../features/human/Mascot'; import { BackendMascot } from '../../../features/human/Mascot/backend/BackendMascot'; import type { MascotDetail, MascotSummary } from '../../../features/human/Mascot/backend/types'; -import { getMascotPalette, type MascotColor } from '../../../features/human/Mascot/mascotPalette'; +import { + getMascotPalette, + hexToArgbInt, + type MascotColor, +} from '../../../features/human/Mascot/mascotPalette'; import { synthesizeSpeech } from '../../../features/human/voice/ttsClient'; import { useT } from '../../../lib/i18n/I18nContext'; import { fetchMascotList, getCachedMascotDetail } from '../../../services/mascotService'; @@ -13,6 +17,8 @@ import { isCustomMascotGifUrl, type MascotVoiceGender, selectCustomMascotGifUrl, + selectCustomPrimaryColor, + selectCustomSecondaryColor, selectEffectiveMascotVoiceId, selectMascotColor, selectMascotVoiceGender, @@ -20,6 +26,8 @@ import { selectMascotVoiceUseLocaleDefault, selectSelectedMascotId, setCustomMascotGifUrl, + setCustomPrimaryColor, + setCustomSecondaryColor, setMascotColor, setMascotVoiceGender, setMascotVoiceId, @@ -47,7 +55,7 @@ const COLOR_OPTIONS: ColorOption[] = [ { id: 'burgundy', labelKey: 'settings.mascot.colorBurgundy' }, { id: 'black', labelKey: 'settings.mascot.colorBlack' }, { id: 'navy', labelKey: 'settings.mascot.colorNavy' }, - { id: 'green', labelKey: 'settings.mascot.colorGreen' }, + { id: 'custom', labelKey: 'settings.mascot.colorCustom' }, ]; const MascotPanel = () => { @@ -55,6 +63,8 @@ const MascotPanel = () => { const { navigateBack, breadcrumbs } = useSettingsNavigation(); const dispatch = useAppDispatch(); const storedColor = useAppSelector(selectMascotColor); + const customPrimary = useAppSelector(selectCustomPrimaryColor); + const customSecondary = useAppSelector(selectCustomSecondaryColor); const selectedMascotId = useAppSelector(selectSelectedMascotId); const customMascotGifUrl = useAppSelector(selectCustomMascotGifUrl); const storedVoiceId = useAppSelector(selectMascotVoiceId); @@ -280,6 +290,16 @@ const MascotPanel = () => { const visibleActiveDetail = selectedMascotId ? activeDetail : null; const visibleDetailError = selectedMascotId ? detailError : null; + const activePalette = getMascotPalette(activeColor); + const primaryColorArgb = useMemo( + () => hexToArgbInt(activeColor === 'custom' ? customPrimary : activePalette.bodyFill), + [activeColor, customPrimary, activePalette] + ); + const secondaryColorArgb = useMemo( + () => hexToArgbInt(activeColor === 'custom' ? customSecondary : activePalette.neckShadowColor), + [activeColor, customSecondary, activePalette] + ); + return (
{ />
+
+
+ +
+
+

{t('settings.mascot.colorHeading')} @@ -328,7 +359,13 @@ const MascotPanel = () => { ? 'border-primary-500 shadow-soft' : 'border-stone-200 dark:border-neutral-800' }`} - style={{ backgroundColor: palette.bodyFill }} + style={ + opt.id === 'custom' + ? { + background: `linear-gradient(135deg, ${customPrimary} 50%, ${customSecondary} 50%)`, + } + : { backgroundColor: palette.bodyFill } + } /> {label} @@ -337,6 +374,38 @@ const MascotPanel = () => {

)}
+ {activeColor === 'custom' && ( +
+ + +
+ )}

{t('settings.mascot.colorDesc')}

diff --git a/app/src/components/settings/panels/__tests__/MascotPanel.test.tsx b/app/src/components/settings/panels/__tests__/MascotPanel.test.tsx index 6f126aab76..0dec59aa46 100644 --- a/app/src/components/settings/panels/__tests__/MascotPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/MascotPanel.test.tsx @@ -24,6 +24,17 @@ vi.mock('../../../../services/mascotService', () => ({ getCachedMascotDetail: (...args: unknown[]) => getCachedMascotDetailMock(...args), })); +vi.mock('../../../../features/human/Mascot', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + RiveMascot: () =>
, + CustomGifMascot: ({ src }: { src: string }) => ( + + ), + }; +}); + vi.mock('../../../../features/human/Mascot/backend/BackendMascot', () => ({ BackendMascot: ({ mascot }: { mascot: { id: string } }) => (
@@ -64,7 +75,7 @@ describe('MascotPanel', () => { it('renders a radio swatch for each supported color', () => { renderPanel(); expect(screen.getByRole('radiogroup', { name: 'OpenHuman color' })).toBeInTheDocument(); - for (const label of ['Yellow', 'Burgundy', 'Black', 'Navy', 'Green']) { + for (const label of ['Yellow', 'Burgundy', 'Black', 'Navy', 'Custom']) { expect(screen.getByRole('radio', { name: label })).toBeInTheDocument(); } }); @@ -85,13 +96,13 @@ describe('MascotPanel', () => { it('is a no-op when clicking the already-selected color', () => { const store = buildStore(); - store.dispatch(setMascotColor('green')); + store.dispatch(setMascotColor('custom')); const dispatchSpy = vi.spyOn(store, 'dispatch'); renderPanel(store); - fireEvent.click(screen.getByRole('radio', { name: 'Green' })); + fireEvent.click(screen.getByRole('radio', { name: 'Custom' })); // No additional dispatches beyond what React-Redux did to subscribe. expect(dispatchSpy).not.toHaveBeenCalled(); - expect(store.getState().mascot.color).toBe('green'); + expect(store.getState().mascot.color).toBe('custom'); }); it('invokes navigateBack from the header back button', () => { @@ -130,14 +141,14 @@ describe('MascotPanel — mascotSlice rehydrate guard', () => { it('ignores REHYDRATE actions for other slice keys', () => { const store = configureStore({ reducer: { mascot: mascotReducer } }); store.dispatch(setMascotColor('navy')); - store.dispatch({ type: REHYDRATE, key: 'someOtherSlice', payload: { color: 'green' } }); + store.dispatch({ type: REHYDRATE, key: 'someOtherSlice', payload: { color: 'custom' } }); // Should remain navy — we only handle key === 'mascot'. expect(store.getState().mascot.color).toBe('navy'); }); it('renders the rehydrated color as selected in the panel', () => { const store = configureStore({ reducer: { mascot: mascotReducer } }); - store.dispatch({ type: REHYDRATE, key: 'mascot', payload: { color: 'green' } }); + store.dispatch({ type: REHYDRATE, key: 'mascot', payload: { color: 'custom' } }); render( @@ -145,7 +156,7 @@ describe('MascotPanel — mascotSlice rehydrate guard', () => { ); - expect(screen.getByRole('radio', { name: 'Green' })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('radio', { name: 'Custom' })).toHaveAttribute('aria-checked', 'true'); expect(screen.getByRole('radio', { name: 'Yellow' })).toHaveAttribute('aria-checked', 'false'); }); diff --git a/app/src/features/human/HumanPage.test.tsx b/app/src/features/human/HumanPage.test.tsx index a13582fa7c..a3764f05b6 100644 --- a/app/src/features/human/HumanPage.test.tsx +++ b/app/src/features/human/HumanPage.test.tsx @@ -11,9 +11,9 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { Provider } from 'react-redux'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import chatRuntimeReducer, { setToolTimelineForThread } from '../../store/chatRuntimeSlice'; +import chatRuntimeReducer from '../../store/chatRuntimeSlice'; import mascotReducer, { setCustomMascotGifUrl } from '../../store/mascotSlice'; -import threadReducer, { setSelectedThread } from '../../store/threadSlice'; +import threadReducer from '../../store/threadSlice'; // ── Static import (after mocks are hoisted) ────────────────────────────── import HumanPage from './HumanPage'; @@ -23,15 +23,19 @@ vi.mock('../../pages/Conversations', () => ({ default: () =>
, })); -vi.mock('./Mascot', () => ({ - YellowMascot: () =>
, - CustomGifMascot: ({ src, face }: { src: string; face?: string }) => ( - - ), - Ghosty: ({ face, bodyColor }: { face?: string; bodyColor?: string }) => ( -
- ), -})); +vi.mock('./Mascot', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + RiveMascot: () =>
, + CustomGifMascot: ({ src, face }: { src: string; face?: string }) => ( + + ), + Ghosty: ({ face, bodyColor }: { face?: string; bodyColor?: string }) => ( +
+ ), + }; +}); vi.mock('./useHumanMascot', () => ({ useHumanMascot: () => ({ face: 'idle', visemes: [] }) })); @@ -105,44 +109,6 @@ describe('HumanPage — speak-replies localStorage persistence', () => { expect(checkbox).toBeChecked(); }); - it('renders sub-mascots for the selected thread subagent timeline', () => { - const store = buildMinimalStore(); - store.dispatch(setSelectedThread('thread-subagents')); - store.dispatch( - setToolTimelineForThread({ - threadId: 'thread-subagents', - entries: [ - { - id: 'thread-subagents:subagent:sub-1:researcher', - name: 'subagent:researcher', - round: 1, - status: 'running', - detail: 'Research the latest docs and report back.', - subagent: { - taskId: 'sub-1', - agentId: 'researcher', - childIteration: 1, - childMaxIterations: 3, - toolCalls: [], - }, - }, - ], - }) - ); - - renderHumanPage(store); - - expect(screen.getByTestId('sub-mascot-layer')).toBeInTheDocument(); - expect( - screen.getByRole('status', { name: /researcher subagent running/i }) - ).toBeInTheDocument(); - // The bubble renders only the label; activity moved to the title tooltip. - expect(screen.getByText('Researcher')).toBeInTheDocument(); - // Activity is in the title attribute of the bubble, not visible body text. - const bubble = screen.getByTestId('sub-mascot-bubble'); - expect(bubble).toHaveAttribute('title', expect.stringContaining('Iteration 1/3')); - }); - it('renders a custom GIF mascot when one is configured', () => { const store = buildMinimalStore(); store.dispatch(setCustomMascotGifUrl('https://example.com/avatar.gif')); diff --git a/app/src/features/human/HumanPage.tsx b/app/src/features/human/HumanPage.tsx index 65c8036bc1..960a9886ed 100644 --- a/app/src/features/human/HumanPage.tsx +++ b/app/src/features/human/HumanPage.tsx @@ -1,20 +1,19 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; import Conversations from '../../pages/Conversations'; -import type { ToolTimelineEntry } from '../../store/chatRuntimeSlice'; import { useAppSelector } from '../../store/hooks'; -import { selectCustomMascotGifUrl, selectMascotColor } from '../../store/mascotSlice'; -import { CustomGifMascot, YellowMascot } from './Mascot'; -import { SubMascotLayer } from './SubMascotLayer'; +import { + selectCustomMascotGifUrl, + selectCustomPrimaryColor, + selectCustomSecondaryColor, + selectMascotColor, +} from '../../store/mascotSlice'; +import { CustomGifMascot, getMascotPalette, hexToArgbInt, RiveMascot } from './Mascot'; import { useHumanMascot } from './useHumanMascot'; const SPEAK_REPLIES_KEY = 'human.speakReplies'; -// Stable empty reference so useAppSelector's === equality doesn't force a re-render -// of SubMascotLayer on every store update when no subagent timeline is active. -const EMPTY_TIMELINE: ToolTimelineEntry[] = []; - const HumanPage = () => { const { t } = useT(); const [speakReplies, setSpeakReplies] = useState(() => { @@ -26,19 +25,21 @@ const HumanPage = () => { window.localStorage.setItem(SPEAK_REPLIES_KEY, speakReplies ? '1' : '0'); }, [speakReplies]); - // Visemes are intentionally unused — the YellowMascot has its own talking lipsync. const { face } = useHumanMascot({ speakReplies }); const mascotColor = useAppSelector(selectMascotColor); + const customPrimary = useAppSelector(selectCustomPrimaryColor); + const customSecondary = useAppSelector(selectCustomSecondaryColor); const customMascotGifUrl = useAppSelector(selectCustomMascotGifUrl); - const subMascotTimeline = useAppSelector(state => { - const threadId = state.thread.selectedThreadId ?? state.thread.activeThreadId; - return threadId - ? (state.chatRuntime.toolTimelineByThread[threadId] ?? EMPTY_TIMELINE) - : EMPTY_TIMELINE; - }); + const palette = getMascotPalette(mascotColor); + const primaryColor = useMemo( + () => hexToArgbInt(mascotColor === 'custom' ? customPrimary : palette.bodyFill), + [mascotColor, customPrimary, palette] + ); + const secondaryColor = useMemo( + () => hexToArgbInt(mascotColor === 'custom' ? customSecondary : palette.neckShadowColor), + [mascotColor, customSecondary, palette] + ); - // Sidebar reserves ~436px (420px panel + 16px gutter) on the right; the - // mascot stage takes the remaining width so the two never overlap. return (
{ {customMascotGifUrl ? ( ) : ( - + )} -
diff --git a/app/src/features/human/Mascot/RiveMascot.tsx b/app/src/features/human/Mascot/RiveMascot.tsx new file mode 100644 index 0000000000..a2349f0703 --- /dev/null +++ b/app/src/features/human/Mascot/RiveMascot.tsx @@ -0,0 +1,89 @@ +import { + Fit, + Layout, + useRive, + useViewModel, + useViewModelInstance, + useViewModelInstanceBoolean, + useViewModelInstanceColor, + useViewModelInstanceString, +} from '@rive-app/react-webgl2'; +import { type FC, useEffect } from 'react'; + +import type { MascotFace } from './Ghosty'; +import type { VisemeId } from './visemes'; + +export interface RiveMascotProps { + face?: MascotFace; + size?: number | string; + primaryColor?: number; + secondaryColor?: number; + viseme?: VisemeId; +} + +const SPEAKING_FACES: ReadonlySet = new Set(['speaking', 'happy']); + +const FACE_TO_POSE: Record = { + idle: 'idle', + normal: 'idle', + sleep: 'sleeping', + listening: 'idle', + thinking: 'thinking', + confused: 'thinking', + speaking: 'idle', + happy: 'idle', + concerned: 'idle', +}; + +const RIVE_LAYOUT = new Layout({ fit: Fit.Contain }); + +export const RiveMascot: FC = ({ + face = 'idle', + size = '100%', + primaryColor, + secondaryColor, + viseme = 'REST', +}) => { + const { rive, RiveComponent } = useRive({ + src: '/tiny_mascot.riv', + stateMachines: 'Main State Machine', + autoplay: true, + layout: RIVE_LAYOUT, + }); + + const viewModel = useViewModel(rive, { useDefault: true }); + const vmInstance = useViewModelInstance(viewModel, { useDefault: true, rive }); + const { setValue: setMouthOpen } = useViewModelInstanceBoolean('mouthOpen', vmInstance); + const { setValue: setPose } = useViewModelInstanceString('pose', vmInstance); + const { setValue: setViseme } = useViewModelInstanceString('viseme', vmInstance); + const { setValue: setPrimaryColor } = useViewModelInstanceColor('primaryColor', vmInstance); + const { setValue: setSecondaryColor } = useViewModelInstanceColor('secondaryColor', vmInstance); + + useEffect(() => { + setMouthOpen(SPEAKING_FACES.has(face!)); + setPose(FACE_TO_POSE[face!] ?? 'idle'); + }, [face, setMouthOpen, setPose]); + + useEffect(() => { + setViseme(viseme); + }, [viseme, setViseme]); + + useEffect(() => { + if (primaryColor !== undefined) setPrimaryColor(primaryColor); + }, [primaryColor, setPrimaryColor]); + + useEffect(() => { + if (secondaryColor !== undefined) setSecondaryColor(secondaryColor); + }, [secondaryColor, setSecondaryColor]); + + return ( +
+ +
+ ); +}; diff --git a/app/src/features/human/Mascot/YellowMascot.test.tsx b/app/src/features/human/Mascot/YellowMascot.test.tsx deleted file mode 100644 index d68da7957a..0000000000 --- a/app/src/features/human/Mascot/YellowMascot.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { render } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; - -import { YellowMascot } from './YellowMascot'; - -describe('', () => { - it('renders an svg by default with the configured face data attribute', () => { - const { container } = render(); - const host = container.querySelector('[data-face]') as HTMLElement; - expect(host).not.toBeNull(); - expect(host.getAttribute('data-face')).toBe('idle'); - expect(container.querySelector('svg')).not.toBeNull(); - }); - - it.each([ - ['idle', 'idle'], - ['sleep', 'sleep'], - ['speaking', 'speaking'], - ['thinking', 'thinking'], - ['confused', 'confused'], - ] as const)('passes %s through to data-face', (face, expected) => { - const { container } = render(); - const host = container.querySelector('[data-face]') as HTMLElement; - expect(host.getAttribute('data-face')).toBe(expected); - }); - - it('renders the sleep face with an svg', () => { - const { container } = render(); - const host = container.querySelector('[data-face="sleep"]') as HTMLElement; - expect(host).not.toBeNull(); - expect(container.querySelector('svg')).not.toBeNull(); - }); - - it('forwards a numeric size prop as a pixel width', () => { - const { container } = render(); - const host = container.querySelector('[data-face]') as HTMLElement; - expect(host.style.width).toBe('48px'); - }); - - it('uses the requested mascot color palette in the rendered svg fills', () => { - const { container: yellow } = render(); - const { container: navy } = render(); - const yellowFill = yellow.querySelector('path[fill]'); - const navyFill = navy.querySelector('path[fill]'); - expect(yellowFill).not.toBeNull(); - expect(navyFill).not.toBeNull(); - expect(yellowFill?.getAttribute('fill')).not.toBe(navyFill?.getAttribute('fill')); - }); -}); diff --git a/app/src/features/human/Mascot/YellowMascot.tsx b/app/src/features/human/Mascot/YellowMascot.tsx deleted file mode 100644 index 14b4112890..0000000000 --- a/app/src/features/human/Mascot/YellowMascot.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { type ComponentType, type FC, useMemo } from 'react'; - -import type { MascotFace } from './Ghosty'; -import type { MascotColor } from './mascotPalette'; -import { FrameProvider, StaticFrameProvider } from './yellow/frameContext'; -import type { MascotProps as YellowMascotInnerProps } from './yellow/MascotCharacter'; -import { YellowMascotIdle } from './yellow/MascotIdle'; -import { YellowMascotTalking } from './yellow/MascotTalking'; -import { YellowMascotThinking } from './yellow/MascotThinking'; - -export interface YellowMascotProps { - /** High-level state from the agent/voice lifecycle. Mapped to a composition. */ - face?: MascotFace; - /** Whether to show the wave arm. Only meaningful in idle/listening states. */ - arm?: 'wave' | 'none'; - /** Override SVG element size; defaults to filling the parent. */ - size?: number | string; - /** Center opacity of the ground shadow gradient — pass through to MascotCharacter. */ - groundShadowOpacity?: number; - /** Use the compact arm shading variant — pass through to MascotCharacter. */ - compactArmShading?: boolean; - /** Mascot color palette. Defaults to yellow. */ - mascotColor?: MascotColor; - /** Render a static (non-animated) pose. Skips the rAF tick used by - * the default animated FrameProvider so decorative instances - * (e.g. subagent indicators) don't churn frames. */ - static?: boolean; -} - -const FPS = 30; -// Logical canvas size reported via useVideoConfig() to the inner compositions. -// They use width/height for layout math (e.g. transform origins). The actual -// on-screen size comes from the wrapper div + the SVG's CSS width/height. -const CANVAS = 1000; -// Loop length per state. The Thinking variant we authored loops cleanly at 6s. -const DURATION_FRAMES = FPS * 6; - -type ExtendedInnerProps = YellowMascotInnerProps & { - groundShadowOpacity?: number; - compactArmShading?: boolean; -}; - -interface Variant { - component: ComponentType; - inputProps: ExtendedInnerProps; -} - -function variantForFace( - face: MascotFace, - arm: 'wave' | 'none', - extras: Pick -): Variant { - const base: Pick< - YellowMascotInnerProps, - 'face' | 'recordingColor' | 'loadingColor' | 'greeting' | 'sleeping' | 'mascotColor' - > = { - face: 'normal', - recordingColor: '#ff3b30', - loadingColor: '#ffffff', - greeting: false, - sleeping: false, - mascotColor: extras.mascotColor ?? 'yellow', - }; - switch (face) { - case 'sleep': - return { - component: YellowMascotIdle, - inputProps: { ...base, sleeping: true, arm: 'none', talking: false, thinking: false }, - }; - case 'thinking': - case 'confused': - return { - component: YellowMascotThinking, - inputProps: { ...base, arm: 'steady', talking: false, thinking: true }, - }; - case 'speaking': - case 'happy': - return { - component: YellowMascotTalking, - inputProps: { ...base, arm: 'steady', talking: true, thinking: false }, - }; - case 'listening': - case 'idle': - case 'normal': - case 'concerned': - default: - return { - component: YellowMascotIdle, - inputProps: { ...base, arm, talking: false, thinking: false }, - }; - } -} - -export const YellowMascot: FC = ({ - face = 'idle', - arm = 'none', - size = '100%', - groundShadowOpacity, - compactArmShading, - mascotColor = 'yellow', - static: isStatic = false, -}) => { - const { Component, inputProps } = useMemo(() => { - const variant = variantForFace(face, arm, { mascotColor }); - const merged: ExtendedInnerProps = { - ...variant.inputProps, - ...(groundShadowOpacity !== undefined ? { groundShadowOpacity } : {}), - ...(compactArmShading !== undefined ? { compactArmShading } : {}), - }; - return { Component: variant.component, inputProps: merged }; - }, [face, arm, mascotColor, groundShadowOpacity, compactArmShading]); - - return ( -
- {/* MascotCharacter sets its 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. */} - - {(() => { - const Provider = isStatic ? StaticFrameProvider : FrameProvider; - return ( - - - - ); - })()} -
- ); -}; diff --git a/app/src/features/human/Mascot/index.ts b/app/src/features/human/Mascot/index.ts index 1d3816fdeb..b346f8ba79 100644 --- a/app/src/features/human/Mascot/index.ts +++ b/app/src/features/human/Mascot/index.ts @@ -2,9 +2,9 @@ export { Ghosty } from './Ghosty'; export type { GhostyProps, MascotFace } from './Ghosty'; export { CustomGifMascot } from './CustomGifMascot'; export type { CustomGifMascotProps } from './CustomGifMascot'; -export { YellowMascot } from './YellowMascot'; -export type { YellowMascotProps } from './YellowMascot'; +export { RiveMascot } from './RiveMascot'; +export type { RiveMascotProps } from './RiveMascot'; export { lerpViseme, VISEMES, visemePath } from './visemes'; export type { VisemeId, VisemeShape } from './visemes'; -export { getMascotPalette } from './mascotPalette'; +export { getMascotPalette, hexToArgbInt } from './mascotPalette'; export type { MascotColor, MascotPalette } from './mascotPalette'; diff --git a/app/src/features/human/Mascot/mascotPalette.test.ts b/app/src/features/human/Mascot/mascotPalette.test.ts index 9400215f7c..5f04aa21e3 100644 --- a/app/src/features/human/Mascot/mascotPalette.test.ts +++ b/app/src/features/human/Mascot/mascotPalette.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { getMascotPalette } from './mascotPalette'; describe('getMascotPalette', () => { - it.each(['yellow', 'burgundy', 'black', 'navy', 'green'] as const)( + it.each(['yellow', 'burgundy', 'black', 'navy', 'custom'] as const)( 'returns a populated palette for %s', color => { const palette = getMascotPalette(color); diff --git a/app/src/features/human/Mascot/mascotPalette.ts b/app/src/features/human/Mascot/mascotPalette.ts index ead40ccf4c..644ecf5d96 100644 --- a/app/src/features/human/Mascot/mascotPalette.ts +++ b/app/src/features/human/Mascot/mascotPalette.ts @@ -1,4 +1,4 @@ -export type MascotColor = 'yellow' | 'burgundy' | 'black' | 'navy' | 'green'; +export type MascotColor = 'yellow' | 'burgundy' | 'black' | 'navy' | 'custom'; export interface MascotPalette { armHighlightMatrix: string; @@ -56,18 +56,17 @@ const palettes: Record = { headShadowMatrix: '0 0 0 0 0.0705882 0 0 0 0 0.14902 0 0 0 0 0.270588 0 0 0 1 0', neckShadowColor: '#16324D', }, - green: { - armHighlightMatrix: '0 0 0 0 0.403922 0 0 0 0 0.654902 0 0 0 0 0.364706 0 0 0 1 0', - armShadowMatrix: '0 0 0 0 0.113725 0 0 0 0 0.270588 0 0 0 0 0.117647 0 0 0 1 0', - bodyFill: '#5FA64F', - bodyHighlightMatrix: '0 0 0 0 0.403922 0 0 0 0 0.654902 0 0 0 0 0.364706 0 0 0 1 0', - bodyShadowMatrix: '0 0 0 0 0.113725 0 0 0 0 0.270588 0 0 0 0 0.117647 0 0 0 1 0', - headHighlightMatrix: '0 0 0 0 0.780392 0 0 0 0 0.894118 0 0 0 0 0.733333 0 0 0 1 0', - headShadowMatrix: '0 0 0 0 0.113725 0 0 0 0 0.270588 0 0 0 0 0.117647 0 0 0 1 0', - neckShadowColor: '#2E5A24', - }, + custom: YELLOW_PALETTE, }; export function getMascotPalette(color: MascotColor): MascotPalette { return palettes[color]; } + +export function hexToArgbInt(hex: string): number { + const h = hex.replace('#', ''); + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + return ((0xff << 24) | (r << 16) | (g << 8) | b) >>> 0; +} diff --git a/app/src/features/human/Mascot/yellow/LoadingFace.tsx b/app/src/features/human/Mascot/yellow/LoadingFace.tsx deleted file mode 100644 index 449ba50cb3..0000000000 --- a/app/src/features/human/Mascot/yellow/LoadingFace.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; - -// Spinning circular loading indicator that replaces the face. -// Centered on the face area (cx=520, cy=545 in the body's local viewBox). -export const LoadingFace: React.FC<{ - frame: number; - fps: number; - color: string; - trackColor?: string; -}> = ({ frame, fps, color, trackColor = '#ffffff' }) => { - // One full rotation every 1.4 seconds. - const rotation = ((frame / fps) * 360) / 1.4; - - const radius = 175; - const stroke = 28; - const circumference = 2 * Math.PI * radius; - // The visible arc occupies ~70% of the circumference; the rest is the gap that spins. - const arc = circumference * 0.7; - - return ( - - {/* Background track. */} - - - {/* Spinning progress arc. */} - - - - - ); -}; diff --git a/app/src/features/human/Mascot/yellow/MascotCharacter.tsx b/app/src/features/human/Mascot/yellow/MascotCharacter.tsx deleted file mode 100644 index a35334d06c..0000000000 --- a/app/src/features/human/Mascot/yellow/MascotCharacter.tsx +++ /dev/null @@ -1,906 +0,0 @@ -import { zColor } from '@remotion/zod-types'; -import React from 'react'; -import { AbsoluteFill, Easing, interpolate } from 'remotion'; -import { z } from 'zod'; - -import { getMascotPalette, type MascotColor } from '../mascotPalette'; -import { useCurrentFrame, useVideoConfig } from './frameContext'; -import { LoadingFace } from './LoadingFace'; -import { RecordingFace } from './RecordingFace'; - -export const mascotSchema = z.object({ - arm: z.enum(['wave', 'none', 'steady']).default('wave'), - face: z.enum(['normal', 'recording', 'loading']).default('normal'), - talking: z.boolean().default(false), - sleeping: z.boolean().default(false), - thinking: z.boolean().default(false), - greeting: z.boolean().default(false), - mascotColor: z.enum(['yellow', 'burgundy', 'black', 'navy', 'green']).default('yellow'), - recordingColor: zColor().default('#ff3b30'), - loadingColor: zColor().default('#ffffff'), -}); - -export type MascotProps = z.infer; - -/** - * Mascot character — drives the custom yellow mascot SVG with the same - * animation system as Ghosty: body bob, head-dot drift/squash, arm wave, blink. - * - * Use distinct `idPrefix` values if two instances appear in the same SVG tree - * so filter/gradient IDs don't collide. - */ -type ThinkingTiming = { - /** Seconds at which the idle→thinking ramp begins. Default 1.0. */ - thinkInStartSec?: number; - /** Seconds at which the idle→thinking ramp completes. Default 2.0. */ - thinkInEndSec?: number; - /** Seconds at which the thinking→idle ramp begins. If unset, the pose holds. */ - thinkOutStartSec?: number; - /** Seconds at which the thinking→idle ramp completes. Required if thinkOutStartSec is set. */ - thinkOutEndSec?: number; -}; - -export const MascotCharacter: React.FC< - MascotProps & { - idPrefix?: string; - /** Center opacity of the ground shadow gradient. Defaults to 0.35; - * bump up (e.g. 0.75) when the mascot is rendered very small (e.g. - * the floating mascot window) so the shadow stays readable. */ - groundShadowOpacity?: number; - /** When true, replaces the warm yellow/amber arm inner-shadow tints - * with darker neutrals so the under-arm shading reads as a real - * shadow at very small render sizes (instead of looking like a - * bright halo). */ - compactArmShading?: boolean; - } & ThinkingTiming -> = ({ - arm = 'wave', - face = 'normal', - talking = false, - sleeping = false, - thinking = false, - greeting = false, - mascotColor = 'yellow', - recordingColor = '#ff3b30', - loadingColor = '#ffffff', - idPrefix = 'mascot', - groundShadowOpacity = 0.35, - compactArmShading = false, - thinkInStartSec = 1.0, - thinkInEndSec = 2.0, - thinkOutStartSec, - thinkOutEndSec, -}) => { - const palette = getMascotPalette(mascotColor as MascotColor); - // Arm-shadow color matrices. Default is the warm yellow→amber pair - // that matches the mascot's hand-painted look at full size; in - // compact mode (small render) we kill the yellow highlight and turn - // the amber shadow into a true black so the under-arm reads as a - // single dark mass instead of a noisy halo at low pixel counts. - const armHighlightMatrix = compactArmShading - ? '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0' - : palette.armHighlightMatrix; - const rightArmShadowMatrix = compactArmShading - ? '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0' - : palette.armShadowMatrix; - const leftArmShadowMatrix = compactArmShading - ? '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0' - : palette.armShadowMatrix.replace(/ 1 0$/, ' 0.8 0'); - - const frame = useCurrentFrame(); - const { fps, width, height, durationInFrames } = useVideoConfig(); - - // Snap each periodic oscillator to a whole number of cycles within - // `durationInFrames` so the first and last frames match — the Player loops - // back to frame 0, and any phase mismatch shows up as a visible pop. - const totalSec = durationInFrames / fps; - // Closest frequency (Hz) that completes an integer number of cycles in the duration. - const loopHz = (targetHz: number): number => - Math.max(1, Math.round(targetHz * totalSec)) / totalSec; - // Closest period (frames) that divides the duration into an integer number of cycles. - const loopPeriod = (targetFrames: number): number => - durationInFrames / Math.max(1, Math.round(durationInFrames / targetFrames)); - - // Convert the original `Math.sin((frame/fps) * π * X)` form: angular freq = X/2 Hz. - // Replace X with 2 * loopHz(originalHz) to keep speed close to the design intent. - const ang = (originalHz: number): number => 2 * Math.PI * loopHz(originalHz) * (frame / fps); - - // Gentle bob for the whole character — design freq 0.6 Hz. - const bob = Math.sin(ang(0.6)) * 14; - - // Head dot drifts independently and squashes when pressing into the body. - // Original used a single dotPhase with multiplied factors; split into two - // independent loops so each snaps to an integer cycle count. - const dotDriftX = ang(0.35); // was sin(dotPhase * 0.7) → 0.35 Hz - const dotDriftY = ang(0.5); // was sin(dotPhase) → 0.5 Hz - const dotDx = Math.sin(dotDriftX) * 6; - const dotDy = Math.sin(dotDriftY) * 9; - const press = Math.max(0, Math.sin(dotDriftY)); - const dotSquashY = 1 - 0.08 * press; - const dotSquashX = 1 + 0.05 * press; - - // Right arm wave — keyframe-based hi-wave: 3 swings then a rest pause. - // Period snaps to an integer divisor of the duration. - const easeInOut = Easing.inOut(Easing.cubic); - const wavePeriod = loopPeriod(Math.round(fps * 2.4)); - const frameInCycle = frame % wavePeriod; - const wave = - arm === 'wave' - ? interpolate( - frameInCycle, - [ - 0, - wavePeriod * 0.12, - wavePeriod * 0.25, - wavePeriod * 0.38, - wavePeriod * 0.5, - wavePeriod * 0.62, - wavePeriod * 0.75, - wavePeriod, - ], - [0, -9, 0, -7, 0, -5, 0, 0], - { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: easeInOut } - ) - : 0; - - // Left arm gentle sway — design freq 0.8 Hz. - const leftSway = Math.sin(ang(0.8)) * 7; - - // Steady right arm sway — same freq, slight phase offset (offset is harmless - // for loop-alignment as long as the base freq fits an integer cycle count). - const steadySway = Math.sin(ang(0.8) + 0.3) * 6; - - // Lip sync — design freqs 1.5 and 2.3 Hz. Phase offset preserved. - const talkA = Math.abs(Math.sin(ang(1.5))); - const talkB = Math.abs(Math.sin(ang(2.3) + 1.2)); - const mouthOpen = talking ? Math.max(talkA, talkB * 0.8) : 0; - // Tongue fades in only when mouth is open enough — prevents visible tongue during near-closed frames. - const tongueOpacity = talking ? Math.min(1, Math.max(0, (mouthOpen - 0.15) / 0.35)) : 0; - - // Blink — period snaps to an integer divisor of the duration. - const blinkPeriod = loopPeriod(Math.round(fps * 2.6)); - const blinkOffset = Math.round(blinkPeriod / 2); - const inBlink = (frame + blinkOffset) % blinkPeriod < 6; - const blinkScale = inBlink ? 0.12 : 1; - - // Sleep animation — slow eye-close then floating Zzz. - const sleepStartFrame = sleeping ? Math.round(fps * 2.5) : 99999; - const sleepFullFrame = sleeping ? Math.round(fps * 4.0) : 99999; - const inSleepTransition = sleeping && frame >= sleepStartFrame; - const sleepProgress = sleeping - ? interpolate(frame, [sleepStartFrame, sleepFullFrame], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - easing: Easing.inOut(Easing.cubic), - }) - : 0; - const isAsleep = sleeping && frame >= sleepFullFrame; - - // Eye openness: normal blink while awake, slow droop during sleep transition. - const eyeScale = inSleepTransition ? Math.max(0, 1 - sleepProgress) : blinkScale; - // Suppress blink highlights mid-droop so pupils don't pop on/off. - const effectiveInBlink = inSleepTransition ? false : inBlink; - // Switch to sleep-arc eyes once eyelids have closed. - const showSleepEyes = sleeping && eyeScale <= 0.06; - - // Floating Z letters — staggered, drift up and fade out. - const zPeriod = Math.round(fps * 2.2); - const zBaseStart = sleepFullFrame + Math.round(fps * 0.4); - const getZ = (delay: number, baseX: number, fontSize: number) => { - const startAt = zBaseStart + delay; - if (!isAsleep || frame < startAt) - return { x: baseX, y: 220 as number, opacity: 0 as number, fontSize }; - const cycleFrame = (frame - startAt) % zPeriod; - const t = cycleFrame / zPeriod; - return { - x: baseX + t * 20, - y: 220 - t * 120, - opacity: interpolate(t, [0, 0.1, 0.72, 1], [0, 1, 0.85, 0]), - fontSize, - }; - }; - // Thinking animation — arm raises, head tilts, eyes shift up, mouth changes. - // Ramp up from `thinkInStartSec` → `thinkInEndSec`. If thinkOutStartSec/EndSec - // are provided, ramp back down so the pose returns to idle (loop-friendly). - const thinkStartFrame = thinking ? Math.round(fps * thinkInStartSec) : 99999; - const thinkFullFrame = thinking ? Math.round(fps * thinkInEndSec) : 99999; - const hasOutRamp = thinking && thinkOutStartSec !== undefined && thinkOutEndSec !== undefined; - const thinkOutStartFrame = hasOutRamp ? Math.round(fps * (thinkOutStartSec as number)) : 99999; - const thinkOutEndFrame = hasOutRamp ? Math.round(fps * (thinkOutEndSec as number)) : 99999; - const thinkInProgress = thinking - ? interpolate(frame, [thinkStartFrame, thinkFullFrame], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - easing: Easing.inOut(Easing.cubic), - }) - : 0; - const thinkOutProgress = hasOutRamp - ? interpolate(frame, [thinkOutStartFrame, thinkOutEndFrame], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - easing: Easing.inOut(Easing.cubic), - }) - : 0; - const thinkProgress = Math.max(0, thinkInProgress - thinkOutProgress); - // "Fully in pose" — only true while held between in-ramp end and out-ramp start. - const isThinking = thinking && thinkInProgress >= 1 && thinkOutProgress <= 0; - - // LEFT arm raises toward body/chin for thinking pose (matches reference: arm on viewer's left side). - // Normal left arm droops at ~127° from +x axis; rotating −128° brings it to ~−1° - // (nearly horizontal, pointing right toward body center — "hand near chin" read). - const thinkArmOscillate = isThinking ? Math.sin((frame / fps) * Math.PI * 0.5) * 2 : 0; - const effectiveLeftSway = thinking - ? interpolate(thinkProgress, [0, 1], [leftSway, -128]) + thinkArmOscillate - : leftSway; - - // Right arm stays in normal steady position while thinking. - const rightSteadyAngle = steadySway; - - // Head tilts slightly toward raised arm (left = negative rotation in SVG). - const headTilt = isThinking - ? -4.5 + Math.sin((frame / fps) * Math.PI * 0.38) * 1.8 - : thinking - ? interpolate(thinkProgress, [0, 1], [0, -4.5]) - : 0; - - // Eyes drift up-left — looking toward the raised arm / into the distance. - const thinkEyeX = thinking ? thinkProgress * -6 : 0; - const thinkEyeY = thinking ? thinkProgress * -9 : 0; - - // Greeting — right arm rises from resting to raised, then waves "hi" in a loop. - const greetStartFrame = greeting ? Math.round(fps * 0.8) : 99999; - const greetRaiseEnd = greeting ? Math.round(fps * 1.6) : 99999; - const isGreeting = greeting && frame >= greetStartFrame; - const greetRaiseProgress = greeting - ? interpolate(frame, [greetStartFrame, greetRaiseEnd], [0, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - easing: Easing.out(Easing.cubic), - }) - : 0; - // Raise: wave arm rotates from +52° (arm pointing right/down) up to 0° (arm raised). - const greetRaiseAngle = interpolate(greetRaiseProgress, [0, 1], [52, 0]); - // Hi wave: enthusiastic oscillation after the arm is fully raised. - const greetWavePeriod = Math.round(fps * 1.3); - const greetWaveFrame = - greeting && frame > greetRaiseEnd ? (frame - greetRaiseEnd) % greetWavePeriod : 0; - const greetWaveOscillate = - greeting && frame > greetRaiseEnd - ? interpolate( - greetWaveFrame, - [ - 0, - greetWavePeriod * 0.25, - greetWavePeriod * 0.5, - greetWavePeriod * 0.75, - greetWavePeriod, - ], - [0, -28, -2, -26, 0], - { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - easing: Easing.inOut(Easing.cubic), - } - ) - : 0; - const greetArmAngle = greetRaiseAngle + greetWaveOscillate; - - const z1 = getZ(0, 605, 40); - const z2 = getZ(Math.round(fps * 0.72), 624, 56); - const z3 = getZ(Math.round(fps * 1.44), 643, 76); - - const size = Math.min(width, height) * 0.85; - const p = (k: string) => `${idPrefix}-${k}`; - - return ( - - - - {/* 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. */} - - - - - - - {/* filter0: body — inner shadows + grain texture */} - - - - - - - - - - - - - - - - - - {/* filter1: head circle — inner shadows + grain texture */} - - - - - - - - - - - - - - - - - - {/* filter2: neck shadow 1 — blur */} - - - - - - - {/* filter3: neck shadow 2 — blur */} - - - - - - - {/* filter4: right arm — inner shadows + grain texture */} - - - - - - - - - - - - - - - - - - {/* filter5: left arm — inner shadows + grain texture */} - - - - - - - - - - - - - - - - - - {/* filter6-7: left eye highlights */} - - - - - - - - - - - - {/* filter8-10: right eye highlights */} - - - - - - - - - - - - - - - - - {/* filter13: steady right arm (idle pose) — mirrors left arm, inner shadows + grain */} - - - - - - - - - - - - - - - - - - {/* filter11-12: cheek highlights */} - - - - - - - - - - - - - {/* Ground shadow — scales with bob so it feels grounded. */} - - - - - {/* Everything bobs together. */} - - {/* Head dot — drifts + squashes independently inside the bob group. */} - - - - - {/* Body */} - - - {/* Waving right arm — normal wave OR greeting raise+hi-wave. */} - {(arm === 'wave' || isGreeting) && ( - - - - )} - - {/* Steady right arm — hidden once greeting raise begins. */} - {arm === 'steady' && !isGreeting && ( - - - - )} - - {/* Left arm — gentle sway in idle; rotates up toward body center while thinking. */} - - - - - {/* Neck shadow details */} - - - - - - - - {/* Normal face — eyes, cheeks, mouth. - Wrapped in a rotation group for the thinking head-tilt. */} - {face === 'normal' && ( - - {/* Sleep eyes — curved closed-lid arcs, visible only when eyeScale ≈ 0 */} - {showSleepEyes && ( - <> - - - - )} - - {/* Left eye — scaleY collapses on blink/sleep; translate shifts gaze while thinking */} - {!showSleepEyes && ( - - - - {!effectiveInBlink && ( - <> - - - - - - - - )} - - - )} - - {/* Right eye — same blink / sleep; translate shifts gaze while thinking */} - {!showSleepEyes && ( - - - - {!effectiveInBlink && ( - <> - - - - - - - - - - - )} - - - )} - - {/* Left cheek */} - - - - - - {/* Right cheek */} - - - - - - {/* Mouth — normal smile fades to a concerned "hmm" when thinking */} - {!talking && ( - <> - {/* Normal closed smile — fades out as thinking kicks in */} - - - - - {/* Thinking / "hmm" mouth — asymmetric slight frown, fades in */} - {thinking && ( - - )} - - )} - - {/* 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 && ( - - {/* Outer mouth: wide rounded top, deep U-curve bottom */} - - {/* Tongue — centered, safely inside mouth at full open. - Fades in so it's invisible while mouth is nearly closed. */} - - {/* Specular highlight on tongue */} - - - )} - - )} - - {/* 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' && ( - - - - )} - - {/* Loading face — spinning ring, same center/scale as recording dot (495, 495, 70%). */} - {face === 'loading' && ( - - - - )} - - {/* Zzz — floating letters that drift up after mascot falls asleep */} - {isAsleep && ( - <> - - Z - - - Z - - - Z - - - )} - - - - ); -}; diff --git a/app/src/features/human/Mascot/yellow/MascotIdle.tsx b/app/src/features/human/Mascot/yellow/MascotIdle.tsx deleted file mode 100644 index 025ac31042..0000000000 --- a/app/src/features/human/Mascot/yellow/MascotIdle.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; - -import { MascotCharacter, type MascotProps, mascotSchema } from './MascotCharacter'; - -// Variant: idle mascot (no arm wave). -export const yellowMascotIdleSchema = mascotSchema; -export type YellowMascotIdleProps = MascotProps; - -export const YellowMascotIdle: React.FC = props => ( - -); diff --git a/app/src/features/human/Mascot/yellow/MascotTalking.tsx b/app/src/features/human/Mascot/yellow/MascotTalking.tsx deleted file mode 100644 index 923d38b2d7..0000000000 --- a/app/src/features/human/Mascot/yellow/MascotTalking.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; - -import { MascotCharacter, type MascotProps, mascotSchema } from './MascotCharacter'; - -// Variant: idle mascot (steady arms) with lip-sync mouth animation. -export const yellowMascotTalkingSchema = mascotSchema; -export type YellowMascotTalkingProps = MascotProps; - -export const YellowMascotTalking: React.FC = props => ( - -); diff --git a/app/src/features/human/Mascot/yellow/MascotThinking.tsx b/app/src/features/human/Mascot/yellow/MascotThinking.tsx deleted file mode 100644 index d2f7d6fb3b..0000000000 --- a/app/src/features/human/Mascot/yellow/MascotThinking.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { FC } from 'react'; -import { z } from 'zod'; - -import { useVideoConfig } from './frameContext'; -import { MascotCharacter, mascotSchema } from './MascotCharacter'; - -export const yellowMascotThinkingSchema = mascotSchema.extend({ - thinking: z.boolean().default(true), -}); -export type YellowMascotThinkingProps = z.infer; - -// Variant: starts idle, ramps into a thinking pose, holds, then ramps back to idle — -// so the first and last frames match and the composition loops cleanly. -// Ramp-in starts almost immediately so the action reads quickly. -export const YellowMascotThinking: FC = props => { - const { fps, durationInFrames } = useVideoConfig(); - const totalSec = durationInFrames / fps; - - // Quick entrance so the pose is visible early in the loop. - const thinkInStartSec = 0.15; - const thinkInEndSec = 0.85; - // Exit ramps back to idle and finishes exactly on the last frame. - const thinkOutEndSec = totalSec; - const thinkOutStartSec = Math.max(thinkInEndSec + 0.2, totalSec - 0.85); - - return ( - - ); -}; diff --git a/app/src/features/human/Mascot/yellow/RecordingFace.tsx b/app/src/features/human/Mascot/yellow/RecordingFace.tsx deleted file mode 100644 index 816c052de2..0000000000 --- a/app/src/features/human/Mascot/yellow/RecordingFace.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; - -// Big pulsing red dot that replaces the face when Ghosty is recording. -// Centered on the face area (cx=520, cy=545 in the body's local viewBox). -export const RecordingFace: React.FC<{ frame: number; fps: number; color: string }> = ({ - frame, - fps, - color, -}) => { - // Smooth pulse: 0..1..0 over ~1.4s. - const t = (frame / fps) * Math.PI * (2 / 1.4); - const pulse = 0.5 + 0.5 * Math.sin(t); - - const baseR = 190; - const dotR = baseR + pulse * 10; - - return ( - - {/* Outer glow halo — expands and fades as the pulse rises. */} - - - - {/* Solid red dot. */} - - - {/* Specular highlight. */} - - - ); -}; diff --git a/app/src/features/human/Mascot/yellow/frameContext.test.tsx b/app/src/features/human/Mascot/yellow/frameContext.test.tsx deleted file mode 100644 index 43b1a94f71..0000000000 --- a/app/src/features/human/Mascot/yellow/frameContext.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { act, render } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { FrameProvider, useCurrentFrame, useVideoConfig } from './frameContext'; - -interface RAFCallback { - (now: number): void; -} - -function mockRequestAnimationFrame() { - const callbacks = new Map(); - let nextId = 1; - - const raf = vi.fn((cb: RAFCallback): number => { - const id = nextId++; - callbacks.set(id, cb); - return id; - }); - const caf = vi.fn((id: number) => { - callbacks.delete(id); - }); - - const tickTo = (now: number) => { - const pending = Array.from(callbacks.entries()); - callbacks.clear(); - for (const [, cb] of pending) cb(now); - }; - - return { raf, caf, tickTo }; -} - -describe('frameContext', () => { - let original: { - raf: typeof window.requestAnimationFrame; - caf: typeof window.cancelAnimationFrame; - }; - let mock: ReturnType; - - beforeEach(() => { - mock = mockRequestAnimationFrame(); - original = { raf: window.requestAnimationFrame, caf: window.cancelAnimationFrame }; - window.requestAnimationFrame = mock.raf as unknown as typeof window.requestAnimationFrame; - window.cancelAnimationFrame = mock.caf as unknown as typeof window.cancelAnimationFrame; - }); - - afterEach(() => { - window.requestAnimationFrame = original.raf; - window.cancelAnimationFrame = original.caf; - }); - - it('exposes the configured video config to consumers', () => { - let captured: ReturnType | null = null; - const Probe = () => { - captured = useVideoConfig(); - return null; - }; - render( - - - - ); - expect(captured).toEqual({ fps: 30, width: 500, height: 500, durationInFrames: 180 }); - }); - - it('starts at frame 0 and advances based on elapsed time', () => { - let frame = -1; - const Probe = () => { - frame = useCurrentFrame(); - return null; - }; - render( - - - - ); - // First render before any rAF tick. - expect(frame).toBe(0); - // Advance 0.5s — at 30fps this is frame 15. - act(() => mock.tickTo(0)); - act(() => mock.tickTo(500)); - expect(frame).toBe(15); - // Advance another 0.5s — frame 30. - act(() => mock.tickTo(1000)); - expect(frame).toBe(30); - }); - - it('loops back to frame 0 after durationInFrames', () => { - let frame = -1; - const Probe = () => { - frame = useCurrentFrame(); - return null; - }; - render( - - - - ); - act(() => mock.tickTo(0)); - // 2 seconds at 30fps = 60 frames → wraps to 0. - act(() => mock.tickTo(2000)); - expect(frame).toBe(0); - // 2.5s = 75 frames → 75 % 60 = 15. - act(() => mock.tickTo(2500)); - expect(frame).toBe(15); - }); - - it('throws when useVideoConfig is used outside FrameProvider', () => { - const Probe = () => { - useVideoConfig(); - return null; - }; - // Suppress React's error logging for this throw-on-render case. - const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - expect(() => render()).toThrow(/useVideoConfig/); - errSpy.mockRestore(); - }); -}); diff --git a/app/src/features/human/Mascot/yellow/frameContext.tsx b/app/src/features/human/Mascot/yellow/frameContext.tsx deleted file mode 100644 index 55b8c54de6..0000000000 --- a/app/src/features/human/Mascot/yellow/frameContext.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { - createContext, - type FC, - type ReactNode, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; - -/** - * Local replacements for Remotion's `useCurrentFrame` and `useVideoConfig`. - * - * `@remotion/player` was reliably starting only after the user blurred and - * refocused the window in CEF — its internal play() races with audio-context / - * focus-event scheduling on cold mount and the SVG paints frame 0 then sits - * idle. Since the mascot compositions only use `useCurrentFrame` / - * `useVideoConfig` from Remotion (everything else is pure utilities like - * `interpolate` / `Easing`), we drive frame ticks ourselves via - * requestAnimationFrame and feed both hooks via plain React context. - */ - -export interface FrameConfig { - fps: number; - width: number; - height: number; - durationInFrames: number; -} - -// Exported so callers (e.g. the meet camera frame producer) can plug in -// a non-rAF tick source — rAF is throttled when the main window is -// backgrounded behind another Tauri window, which freezes the mascot. -export const FrameContext = createContext(0); -export const FrameConfigContext = createContext(null); - -export const useCurrentFrame = (): number => useContext(FrameContext); - -export const useVideoConfig = (): FrameConfig => { - const cfg = useContext(FrameConfigContext); - if (!cfg) { - throw new Error('useVideoConfig() must be used inside '); - } - return cfg; -}; - -interface FrameProviderProps extends FrameConfig { - children: ReactNode; -} - -export const FrameProvider: FC = ({ - fps, - width, - height, - durationInFrames, - children, -}) => { - const [frame, setFrame] = useState(0); - const startRef = useRef(null); - - useEffect(() => { - let raf = 0; - const tick = (now: number) => { - if (startRef.current === null) startRef.current = now; - const elapsed = now - startRef.current; - const next = Math.floor((elapsed / 1000) * fps) % durationInFrames; - setFrame(prev => (prev === next ? prev : next)); - raf = window.requestAnimationFrame(tick); - }; - raf = window.requestAnimationFrame(tick); - return () => window.cancelAnimationFrame(raf); - }, [fps, durationInFrames]); - - const config = useMemo( - () => ({ fps, width, height, durationInFrames }), - [fps, width, height, durationInFrames] - ); - - return ( - - {children} - - ); -}; - -/** - * Static variant of {@link FrameProvider} — pins the frame at 0 and never - * schedules a requestAnimationFrame. Use this for decorative mascot - * instances (e.g. small subagent indicators) where motion would be - * distracting and the per-frame rAF cost across N mascots is wasteful. - */ -export const StaticFrameProvider: FC = ({ - fps, - width, - height, - durationInFrames, - children, -}) => { - const config = useMemo( - () => ({ fps, width, height, durationInFrames }), - [fps, width, height, durationInFrames] - ); - - return ( - - {children} - - ); -}; diff --git a/app/src/features/human/SubMascotLayer.test.tsx b/app/src/features/human/SubMascotLayer.test.tsx index 2152a85097..e97e68ac2b 100644 --- a/app/src/features/human/SubMascotLayer.test.tsx +++ b/app/src/features/human/SubMascotLayer.test.tsx @@ -1,9 +1,17 @@ import { render, screen, within } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { ToolTimelineEntry } from '../../store/chatRuntimeSlice'; import { SubMascotLayer, subMascotModelsFromTimeline } from './SubMascotLayer'; +vi.mock('./Mascot', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + RiveMascot: ({ face }: { face?: string }) =>
, + }; +}); + function subagentEntry(overrides: Partial = {}): ToolTimelineEntry { return { id: 'thread-1:subagent:sub-1:researcher', diff --git a/app/src/features/human/SubMascotLayer.tsx b/app/src/features/human/SubMascotLayer.tsx index 20add8d349..6c42afb943 100644 --- a/app/src/features/human/SubMascotLayer.tsx +++ b/app/src/features/human/SubMascotLayer.tsx @@ -2,7 +2,7 @@ import debug from 'debug'; import { type FC, useMemo } from 'react'; import type { ToolTimelineEntry, ToolTimelineEntryStatus } from '../../store/chatRuntimeSlice'; -import { type MascotFace, YellowMascot } from './Mascot'; +import { type MascotFace, RiveMascot } from './Mascot'; import type { MascotColor } from './Mascot/mascotPalette'; const subMascotLog = debug('human:sub-mascots'); @@ -10,13 +10,7 @@ const subMascotLog = debug('human:sub-mascots'); const MAX_SUB_MASCOTS = 5; const ACTIVITY_LIMIT = 74; -const SUB_MASCOT_COLORS: readonly MascotColor[] = [ - 'yellow', - 'green', - 'navy', - 'burgundy', - 'black', -] as const; +const SUB_MASCOT_COLORS: readonly MascotColor[] = ['yellow', 'navy', 'burgundy', 'black'] as const; const POSITIONS = [ { left: '72%', top: '18%' }, @@ -166,7 +160,7 @@ export const SubMascotLayer: FC = ({ entries }) => { model.status === 'running' ? 'opacity-100' : 'opacity-75', ].join(' ')}>
- +