From 7320da2f71b00bf1d1c33043f2b988ac92f3150c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 25 May 2026 19:14:08 -0700 Subject: [PATCH 01/11] feat(mascot): replace SVG animation system with Rive renderer Switch from the custom Remotion/SVG frame-based mascot to a Rive file (`tiny_mascot.riv`) rendered via `@rive-app/react-webgl2`. The Rive state machine drives body, eye, and mouth animations natively; the `mouthOpen` ViewModel boolean is toggled from the `face` prop to animate speaking states. - Add RiveMascot component using Rive ViewModel data binding - Remove YellowMascot, yellow/ SVG compositions, and FrameProvider - Replace YellowMascot in HumanPage, MascotWindowApp, iOS MascotScreen, and MascotFrameProducer (now captures Rive canvas instead of SVG) - Temporarily disable SubMascotLayer rendering in HumanPage - Update test mocks to match new component names --- app/package.json | 3 +- app/public/tiny_mascot.riv | Bin 0 -> 8044 bytes app/src/features/human/HumanPage.test.tsx | 44 +- app/src/features/human/HumanPage.tsx | 23 +- app/src/features/human/Mascot/RiveMascot.tsx | 48 + .../human/Mascot/YellowMascot.test.tsx | 49 - .../features/human/Mascot/YellowMascot.tsx | 144 --- app/src/features/human/Mascot/index.ts | 4 +- .../human/Mascot/yellow/LoadingFace.tsx | 49 - .../human/Mascot/yellow/MascotCharacter.tsx | 906 ------------------ .../human/Mascot/yellow/MascotIdle.tsx | 11 - .../human/Mascot/yellow/MascotTalking.tsx | 11 - .../human/Mascot/yellow/MascotThinking.tsx | 41 - .../human/Mascot/yellow/RecordingFace.tsx | 42 - .../human/Mascot/yellow/frameContext.test.tsx | 117 --- .../human/Mascot/yellow/frameContext.tsx | 109 --- app/src/features/human/SubMascotLayer.tsx | 4 +- app/src/features/meet/MascotFrameProducer.tsx | 413 +------- app/src/mascot/MascotWindowApp.tsx | 4 +- app/src/pages/ios/MascotScreen.test.tsx | 7 +- app/src/pages/ios/MascotScreen.tsx | 4 +- package.json | 1 + pnpm-lock.yaml | 37 + tiny_mascot.riv | Bin 0 -> 8044 bytes 24 files changed, 157 insertions(+), 1914 deletions(-) create mode 100644 app/public/tiny_mascot.riv create mode 100644 app/src/features/human/Mascot/RiveMascot.tsx delete mode 100644 app/src/features/human/Mascot/YellowMascot.test.tsx delete mode 100644 app/src/features/human/Mascot/YellowMascot.tsx delete mode 100644 app/src/features/human/Mascot/yellow/LoadingFace.tsx delete mode 100644 app/src/features/human/Mascot/yellow/MascotCharacter.tsx delete mode 100644 app/src/features/human/Mascot/yellow/MascotIdle.tsx delete mode 100644 app/src/features/human/Mascot/yellow/MascotTalking.tsx delete mode 100644 app/src/features/human/Mascot/yellow/MascotThinking.tsx delete mode 100644 app/src/features/human/Mascot/yellow/RecordingFace.tsx delete mode 100644 app/src/features/human/Mascot/yellow/frameContext.test.tsx delete mode 100644 app/src/features/human/Mascot/yellow/frameContext.tsx create mode 100644 tiny_mascot.riv 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 0000000000000000000000000000000000000000..a26c614ee705da8ac0bd4a3502c7d7368d683e3e GIT binary patch literal 8044 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 Qp#KxGKtn1jRA9;f04-=X3;+NC literal 0 HcmV?d00001 diff --git a/app/src/features/human/HumanPage.test.tsx b/app/src/features/human/HumanPage.test.tsx index a13582fa7c..53c1d38b7a 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'; @@ -24,7 +24,7 @@ vi.mock('../../pages/Conversations', () => ({ })); vi.mock('./Mascot', () => ({ - YellowMascot: () =>
, + RiveMascot: () =>
, CustomGifMascot: ({ src, face }: { src: string; face?: string }) => ( ), @@ -105,44 +105,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..edbb007222 100644 --- a/app/src/features/human/HumanPage.tsx +++ b/app/src/features/human/HumanPage.tsx @@ -2,19 +2,13 @@ import { useEffect, 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 } from '../../store/mascotSlice'; +import { CustomGifMascot, 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 +20,9 @@ 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 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; - }); - // 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..1bec0317d5 --- /dev/null +++ b/app/src/features/human/Mascot/RiveMascot.tsx @@ -0,0 +1,48 @@ +import { + Fit, + Layout, + useRive, + useViewModel, + useViewModelInstance, + useViewModelInstanceBoolean, +} from '@rive-app/react-webgl2'; +import { type FC, useEffect } from 'react'; + +import type { MascotFace } from './Ghosty'; + +export interface RiveMascotProps { + face?: MascotFace; + size?: number | string; +} + +const SPEAKING_FACES: ReadonlySet = new Set(['speaking', 'happy']); + +const RIVE_LAYOUT = new Layout({ fit: Fit.Contain }); + +export const RiveMascot: FC = ({ face = 'idle', size = '100%' }) => { + const { rive, RiveComponent } = useRive({ + src: '/tiny_mascot.riv', + stateMachines: 'State Machine 1', + autoplay: true, + layout: RIVE_LAYOUT, + }); + + const viewModel = useViewModel(rive, { useDefault: true }); + const vmInstance = useViewModelInstance(viewModel, { useDefault: true, rive }); + const { setValue: setMouthOpen } = useViewModelInstanceBoolean('mouthOpen', vmInstance); + + useEffect(() => { + setMouthOpen(SPEAKING_FACES.has(face!)); + }, [face, setMouthOpen]); + + 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..ea001ce1a2 100644 --- a/app/src/features/human/Mascot/index.ts +++ b/app/src/features/human/Mascot/index.ts @@ -2,8 +2,8 @@ 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'; 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.tsx b/app/src/features/human/SubMascotLayer.tsx index 20add8d349..728ff4be5e 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'); @@ -166,7 +166,7 @@ export const SubMascotLayer: FC = ({ entries }) => { model.status === 'running' ? 'opacity-100' : 'opacity-75', ].join(' ')}>
- +