diff --git a/.gitignore b/.gitignore index a5fa33ceee..415b34cad7 100644 --- a/.gitignore +++ b/.gitignore @@ -163,3 +163,9 @@ src/apps/mobile/android/.kotlin/ /docs/superpowers/ AGENTS.override.md + +# CodeGraph index (local, large) +.codegraph/ + +# Desktop test artifacts +target-desktop-test/ diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3044c221ee..ed7ada1f3a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,6 +13,13 @@ packages: - "website" - "tests/e2e" +allowBuilds: + '@parcel/watcher': false + edgedriver: false + esbuild: true + geckodriver: false + sharp: true + patchedDependencies: "@xterm/xterm@6.1.0-beta.141": patches/@xterm__xterm@6.1.0-beta.141.patch mdast-util-gfm-autolink-literal@2.0.1: patches/mdast-util-gfm-autolink-literal@2.0.1.patch diff --git a/scripts/frontend-color-surface-registry.json b/scripts/frontend-color-surface-registry.json index ca3a32bc3e..dcfa2848c9 100644 --- a/scripts/frontend-color-surface-registry.json +++ b/scripts/frontend-color-surface-registry.json @@ -55,9 +55,11 @@ "src/flow_chat/assets/subagent-avatars/robot-18.svg", "src/flow_chat/assets/subagent-avatars/robot-19.svg", "src/flow_chat/assets/subagent-avatars/robot-20.svg", - "src/tools/generative-ui/components/openui", +"src/tools/generative-ui/components/openui", "src/tools/generative-ui/components/openuiLibrary.tsx", - "src/tools/generative-ui/components/vendor" + "src/tools/generative-ui/components/vendor", + "public/agent-companion-pets/grok-live/preview.svg", + "src/flow_chat/components/grok-live/grok-player.js" ] } }, @@ -525,6 +527,20 @@ } ], "exclusions": [ + { + "id": "grok-live-thumbnail", + "path": "src/web-ui/public/agent-companion-pets/grok-live/preview.svg", + "kind": "authored-vector-artwork", + "owner": "GrokLive preview in AgentCompanionPetService", + "reason": "The transparent pet picker artwork requires an invariant black body and two opaque white eyes; it is an illustration, not theme-dependent UI chrome." + }, + { + "id": "grok-live-vendor-mask", + "path": "src/web-ui/src/flow_chat/components/grok-live/grok-player.js", + "kind": "vendored-renderer", + "owner": "src/web-ui/src/flow_chat/components/grok-live/SOURCE.txt", + "reason": "The local animation renderer needs fixed black and white mask-pixel semantics; this narrowly scoped vendor file is not product UI chrome." + }, { "id": "subagent-hatch-artwork", "path": "design-system/packages/ui/src/brand/assets/subagent-egg.svg", diff --git a/src/apps/desktop/src/appearance.rs b/src/apps/desktop/src/appearance.rs index a2af3d3d8e..42b1ba4ab8 100644 --- a/src/apps/desktop/src/appearance.rs +++ b/src/apps/desktop/src/appearance.rs @@ -14,7 +14,7 @@ use crate::startup_trace::DesktopStartupTrace; const AGENT_COMPANION_WINDOW_LABEL: &str = "agent-companion-pet"; const AGENT_COMPANION_WINDOW_MIN_SIZE: f64 = 96.0; -const AGENT_COMPANION_WINDOW_MAX_WIDTH: f64 = 360.0; +const AGENT_COMPANION_WINDOW_MAX_WIDTH: f64 = 480.0; const AGENT_COMPANION_WINDOW_MAX_HEIGHT: f64 = 240.0; const AGENT_COMPANION_WINDOW_MARGIN: i32 = 64; const AGENT_COMPANION_WINDOW_EDGE_MARGIN: f64 = 8.0; diff --git a/src/web-ui/public/agent-companion-pets/grok-live/preview.svg b/src/web-ui/public/agent-companion-pets/grok-live/preview.svg new file mode 100644 index 0000000000..03622f15cb --- /dev/null +++ b/src/web-ui/public/agent-companion-pets/grok-live/preview.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/web-ui/public/agent-companion-pets/living-line/preview.svg b/src/web-ui/public/agent-companion-pets/living-line/preview.svg new file mode 100644 index 0000000000..e3399bb3d3 --- /dev/null +++ b/src/web-ui/public/agent-companion-pets/living-line/preview.svg @@ -0,0 +1 @@ + diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx index 92b87da952..0b3fb7e2f4 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx @@ -8,6 +8,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite import type { AgentCompanionActivityPayload, AgentCompanionTaskStatus } from '@/flow_chat/utils/agentCompanionActivity'; import { AgentCompanionDesktopPet } from './AgentCompanionDesktopPet'; +const companionPet = vi.hoisted(() => ({ selection: null as any })); const emitMock = vi.hoisted(() => vi.fn(() => Promise.resolve())); const listenMock = vi.hoisted(() => vi.fn()); const invokeMock = vi.hoisted(() => vi.fn(() => Promise.resolve())); @@ -43,17 +44,17 @@ vi.mock('@tauri-apps/api/window', () => ({ vi.mock('@/infrastructure/config/services/AIExperienceConfigService', () => ({ aiExperienceConfigService: { - getSettings: () => ({ agent_companion_pet: null }), + getSettings: () => ({ agent_companion_pet: companionPet.selection }), getSettingsAsync: () => Promise.resolve({ enable_agent_companion: true, - agent_companion_pet: null, + agent_companion_pet: companionPet.selection, }), }, })); vi.mock('@/flow_chat/components/AgentCompanionPet', () => ({ - AgentCompanionPet: ({ lookDirection, action, mood, dragDirection }: { lookDirection?: number | null; action?: string | null; mood?: string; dragDirection?: string }) => ( -
+ AgentCompanionPet: ({ lookDirection, action, mood, dragDirection, livingLineState }: { lookDirection?: number | null; action?: string | null; mood?: string; dragDirection?: string; livingLineState?: string }) => ( +
), })); @@ -147,6 +148,7 @@ describe('AgentCompanionDesktopPet', () => { beforeEach(async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + companionPet.selection = null; emitMock.mockClear(); invokeMock.mockClear(); cursorPositionMock.mockReset(); @@ -200,6 +202,63 @@ describe('AgentCompanionDesktopPet', () => { vi.unstubAllGlobals(); }); + it('reserves the LivingLine rectangle and forwards its persistent state', async () => { + await act(async () => root.unmount()); + companionPet.selection = { + id: 'living-line', displayName: 'LivingLine', source: 'preset', + packagePath: '/agent-companion-pets/living-line', + spritesheetPath: '/agent-companion-pets/living-line/preview.svg', spritesheetMimeType: 'image/svg+xml', + }; + invokeMock.mockClear(); + await act(async () => { + root = createRoot(container); + root.render(); + }); + // Reserve the bubble slot before there is a task, not after it paints. + expect(invokeMock).toHaveBeenLastCalledWith('resize_agent_companion_desktop_pet', { width: 408, height: 102 }); + invokeMock.mockClear(); + pushActivity({ mood: 'rest', tasks: [task({ state: 'error', mood: 'rest' })], livingLineState: 'error', emittedAt: 1, sequence: 1 }); + + expect(query('.openbitfun-agent-companion-window__stack')?.getAttribute('style')).toContain('--openbitfun-agent-companion-pet-width: 216px'); + expect(query('.openbitfun-agent-companion-window__stack')?.getAttribute('style')).toContain('--openbitfun-agent-companion-pet-height: 96px'); + expect(query('[data-testid="pixel-pet"]')?.getAttribute('data-living-line-state')).toBe('error'); + pushActivity({ mood: 'working', tasks: [task(), task({ sessionId: 'second' })], emittedAt: 2, sequence: 2 }); + pushActivity({ mood: 'rest', tasks: [], emittedAt: 3, sequence: 3 }); + await act(async () => { await Promise.resolve(); }); + // No horizontal shrink/grow/reposition cycle for zero, one or two bubbles. + expect(invokeMock).not.toHaveBeenCalledWith('resize_agent_companion_desktop_pet', expect.anything()); + }); + + it('waits for the LivingLine viewport before revealing bubbles and preserves their nodes', async () => { + vi.stubGlobal('innerWidth', 216); + act(() => window.dispatchEvent(new Event('resize'))); + const settingsListener = listenMock.mock.calls.find(([name]) => name === 'agent-companion://settings-updated')?.[1]; + await act(async () => settingsListener({ payload: { + enable_agent_companion: true, + agent_companion_pet: { + id: 'living-line', displayName: 'LivingLine', source: 'preset', + packagePath: '/agent-companion-pets/living-line', + spritesheetPath: '/agent-companion-pets/living-line/preview.svg', spritesheetMimeType: 'image/svg+xml', + }, + } })); + pushActivity({ mood: 'working', tasks: [task()], emittedAt: 1, sequence: 1 }); + const bubbles = query('.openbitfun-agent-companion-window__bubbles')!; + const bubble = query('.openbitfun-agent-companion-window__bubble'); + expect(bubbles.style.visibility).toBe('hidden'); + expect(bubbles.getAttribute('aria-hidden')).toBe('true'); + expect(invokeMock).toHaveBeenLastCalledWith('resize_agent_companion_desktop_pet', { width: 408, height: 102 }); + + vi.stubGlobal('innerWidth', 408); + act(() => window.dispatchEvent(new Event('resize'))); + expect(bubbles.style.visibility).toBe(''); + expect(bubbles.hasAttribute('aria-hidden')).toBe(false); + expect(query('.openbitfun-agent-companion-window__bubble')).toBe(bubble); + invokeMock.mockClear(); + pushActivity({ mood: 'waiting', tasks: [task({ state: 'attention', latestOutput: 'Reply needed' })], emittedAt: 2, sequence: 2 }); + await act(async () => { await Promise.resolve(); }); + expect(invokeMock).not.toHaveBeenCalledWith('resize_agent_companion_desktop_pet', expect.anything()); + }); + it.each(['before activity', 'after activity'])( 'shows a new task without dragging when the main window gains focus %s', focusOrder => { diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx index ac62029043..dbd0ec252a 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx @@ -19,16 +19,14 @@ import { aiExperienceConfigService, type AgentCompanionPetSelection, type AIExpe import { api } from '@/infrastructure/api/service-api/ApiClient'; import { AgentCompanionPet, type AgentCompanionPetMood } from '@/flow_chat/components/AgentCompanionPet'; import type { AgentCompanionMood } from '@/flow_chat/utils/agentCompanionMood'; -import type { - AgentCompanionActivityPayload, - AgentCompanionTaskState, - AgentCompanionTaskStatus, -} from '@/flow_chat/utils/agentCompanionActivity'; +import { deriveLivingLineState, type AgentCompanionActivityPayload, type AgentCompanionTaskState, type AgentCompanionTaskStatus, type LivingLineActivityState } from '@/flow_chat/utils/agentCompanionActivity'; import type { AgentCompanionPetCommand } from '@/app/services/agentCompanionPetCommands'; import { createLogger } from '@/shared/utils/logger'; import { isImeOwnedKeyboardEvent } from '@/shared/utils/ime'; import { isReducedMotionPreferred } from '@/shared/utils/motionPreference'; import { getPetLookDirection } from '@/infrastructure/config/services/agentCompanionPetSprite'; +import { isGrokLivePet } from '@/infrastructure/config/services/grokLivePetIdentity'; +import { isLivingLinePet } from '@/infrastructure/config/services/livingLinePetIdentity'; import { startAgentCompanionDrag } from '@/infrastructure/config/services/AgentCompanionDragService'; import { prepareAgentCompanionPointerDrag, type CompanionPointerDrag } from '@/infrastructure/config/services/AgentCompanionPointerDragService'; import { createCompanionWindowResizer, isCompanionViewportShort } from './agentCompanionWindowResize'; @@ -39,6 +37,7 @@ const DEFAULT_PET_SIZE = 96; const DEFAULT_PETDEX_DISPLAY_SIZE = { width: 96, height: 104 }; const PETDEX_DESKTOP_SCALE = 0.5; const WINDOW_MAX_WIDTH = 360; +const LIVING_LINE_WINDOW_MAX_WIDTH = 480; const WINDOW_MAX_HEIGHT = 240; const WINDOW_HORIZONTAL_GAP = 8; const MAX_VISIBLE_BUBBLES = 2; @@ -61,6 +60,7 @@ const PET_MAX_DISPLAY_SIZE = { width: WINDOW_MAX_WIDTH - BUBBLE_WIDTH - WINDOW_HORIZONTAL_GAP - WINDOW_EDGE_BUFFER, height: WINDOW_MAX_HEIGHT - WINDOW_VERTICAL_BUFFER * 2, }; + const POINTER_HOVER_POLL_INTERVAL_MS = 120; const PET_LOOK_HOLD_MS = 960; /** Clicks shorter/smaller than this use `show_main_window`; beyond it we start dragging. */ @@ -167,6 +167,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { () => aiExperienceConfigService.getSettings().agent_companion_pet ?? null, ); const [mood, setMood] = useState('rest'); + const [livingLineState, setLivingLineState] = useState('idle'); const [tasks, setTasks] = useState([]); const previousTasksRef = useRef(null); const [reaction, setReaction] = useState<{ action: 'jumping' | 'waving' } | null>(null); @@ -192,9 +193,11 @@ export const AgentCompanionDesktopPet: React.FC = () => { const [typedOutputBySessionId, setTypedOutputBySessionId] = useState>({}); const [isHoveringPet, setIsHoveringPet] = useState(false); const [lookDirection, setLookDirection] = useState(null); + const [grokGaze, setGrokGaze] = useState<[number, number] | null>(null); const trackPetLook = mood === 'rest' && pet != null && ( pet.spriteVersionNumber === 2 || (pet.source === 'user' && pet.spriteVersionNumber == null) ); + const trackGrokGaze = isGrokLivePet(pet); const [isDraggingPet, setIsDraggingPet] = useState(false); const [dragDirection, setDragDirection] = useState<'left' | 'right'>('right'); const stopDragRef = useRef<(() => void) | null>(null); @@ -236,11 +239,18 @@ export const AgentCompanionDesktopPet: React.FC = () => { // Task activity and explicit dismissals own visibility. Depending on the // main window's focus made new tasks appear only after touching the pet. const displayTasks = [...visibleTasks].reverse(); + const livingLinePet = isLivingLinePet(pet); + const windowMaxWidth = livingLinePet ? LIVING_LINE_WINDOW_MAX_WIDTH : WINDOW_MAX_WIDTH; + const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth); const activePetSize = pet && petFrameSize ? petFrameSize - : pet - ? DEFAULT_PETDEX_DISPLAY_SIZE - : { width: DEFAULT_PET_SIZE, height: DEFAULT_PET_SIZE }; + : pet && isLivingLinePet(pet) + ? { width: 216, height: 96 } + : pet && isGrokLivePet(pet) + ? { width: DEFAULT_PET_SIZE, height: DEFAULT_PET_SIZE } + : pet + ? DEFAULT_PETDEX_DISPLAY_SIZE + : { width: DEFAULT_PET_SIZE, height: DEFAULT_PET_SIZE }; useEffect(() => { let disposed = false; @@ -301,6 +311,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { lastActivityEmittedAtRef.current = emittedAt; lastActivitySequenceRef.current = sequence; setMood(event.payload.mood); + setLivingLineState(event.payload.livingLineState ?? deriveLivingLineState(event.payload.tasks)); // The first snapshot is hydration, not a new request. if (previousTasksRef.current === null) previousTasksRef.current = event.payload.tasks; setTasks(event.payload.tasks); @@ -489,7 +500,12 @@ export const AgentCompanionDesktopPet: React.FC = () => { ) : activePetSize.height) + WINDOW_VERTICAL_BUFFER * 2; const measuredBubbleWidth = bubbleCount > 0 ? BUBBLE_WIDTH : 0; - const measuredDockWidth = bubbleCount > 0 + // Keep the wide renderer's bubble slot reserved even without a task. Native + // resize and reposition are asynchronous, so growing/shrinking by the pet frame height on + // bubble arrival/removal otherwise exposes a clipped, left-shifted frame. + const measuredDockWidth = livingLinePet + ? BUBBLE_WIDTH + WINDOW_HORIZONTAL_GAP + activePetSize.width + WINDOW_EDGE_BUFFER + : bubbleCount > 0 ? measuredBubbleWidth + WINDOW_HORIZONTAL_GAP + activePetSize.width + WINDOW_EDGE_BUFFER : Math.max( activePetSize.width, @@ -498,7 +514,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { ); const nextWidth = Math.max( activePetSize.width, - Math.min(WINDOW_MAX_WIDTH, Math.ceil(measuredDockWidth)), + Math.min(windowMaxWidth, Math.ceil(measuredDockWidth)), ); // Rounded up so converting to physical pixels cannot shave a row or column. const nextWindowHeight = Math.ceil(nextHeight); @@ -511,8 +527,11 @@ export const AgentCompanionDesktopPet: React.FC = () => { return; } + const target = windowResizer.target(); + if (target?.width === nextWidth && target.height === nextWindowHeight + && !isCompanionViewportShort({ width: window.innerWidth, height: window.innerHeight }, target)) return; windowResizer.request({ width: nextWidth, height: nextWindowHeight }); - }, [activePetSize.height, activePetSize.width, composingSessionId, visibleTasks, windowResizer]); + }, [activePetSize.height, activePetSize.width, composingSessionId, livingLinePet, visibleTasks, windowMaxWidth, windowResizer]); useEffect(() => { // The host can change the window behind the dock's back (display or DPI @@ -520,6 +539,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { // viewport comes out smaller than the dock needs; the host ignores requests // that already match, so this cannot loop. const reassertWindowSize = () => { + setViewportWidth(window.innerWidth); const target = windowResizer.target(); if (target && isCompanionViewportShort( { width: window.innerWidth, height: window.innerHeight }, @@ -539,7 +559,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { }, [windowResizer]); useEffect(() => { - if (IS_WINDOWS_WEBVIEW && !trackPetLook) { + if (IS_WINDOWS_WEBVIEW && !trackPetLook && !trackGrokGaze) { return; } @@ -656,6 +676,17 @@ export const AgentCompanionDesktopPet: React.FC = () => { lastPointerMovedAt = now; lastPointer = { x: pointer.x, y: pointer.y }; } + const gazeX = cachedHitboxRect && !isReducedMotionPreferred() + ? Math.max(-1, Math.min(1, (pointerX - (cachedHitboxRect.left + cachedHitboxRect.width / 2)) / (cachedHitboxRect.width * 2))) + : 0; + const gazeY = cachedHitboxRect && !isReducedMotionPreferred() + ? Math.max(-1, Math.min(1, -(pointerY - (cachedHitboxRect.top + cachedHitboxRect.height / 2)) / (cachedHitboxRect.height * 2))) + : 0; + if (trackGrokGaze) { + setGrokGaze(previous => previous?.[0] === gazeX && previous?.[1] === gazeY ? previous : [gazeX, gazeY]); + } else { + setGrokGaze(null); + } setLookDirection(cachedHitboxRect && now - lastPointerMovedAt < PET_LOOK_HOLD_MS && !isReducedMotionPreferred() ? getPetLookDirection( pointerX - (cachedHitboxRect.left + cachedHitboxRect.width / 2), @@ -689,7 +720,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { if (document.visibilityState === 'hidden') return; // Keep the same visible hover latency, and refresh immediately on return. void pollPointerHover(); - intervalId = window.setInterval(() => { void pollPointerHover(); }, POINTER_HOVER_POLL_INTERVAL_MS); + intervalId = window.setInterval(() => { void pollPointerHover(); }, trackGrokGaze ? 33 : POINTER_HOVER_POLL_INTERVAL_MS); }; document.addEventListener('visibilitychange', syncPointerPolling); syncPointerPolling(); @@ -702,7 +733,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { removeWindowMovedListener?.(); removeScaleChangedListener?.(); }; - }, [trackPetLook]); + }, [trackPetLook, trackGrokGaze]); const showMainWindowFromPet = useCallback(async () => { try { @@ -1041,6 +1072,10 @@ export const AgentCompanionDesktopPet: React.FC = () => { '--openbitfun-agent-companion-gap': `${WINDOW_HORIZONTAL_GAP}px`, '--openbitfun-agent-companion-vertical-buffer': `${WINDOW_VERTICAL_BUFFER}px`, } as React.CSSProperties; + // Keep bubbles measurable but do not paint them outside the old viewport + // while the initial/switch-to-LivingLine native resize is still pending. + const bubblesFit = !livingLinePet + || viewportWidth + 1 >= BUBBLE_WIDTH + WINDOW_HORIZONTAL_GAP + activePetSize.width; const isSingleTask = visibleTasks.length === 1; const hasAttentionTask = visibleTasks.some(task => task.state === 'attention'); const overlayTask = overlay && overlay.kind !== 'pet-menu' @@ -1104,6 +1139,8 @@ export const AgentCompanionDesktopPet: React.FC = () => { ref={bubblesRef} className={`openbitfun-agent-companion-window__bubbles${isSingleTask ? ' openbitfun-agent-companion-window__bubbles--single' : ''}`} aria-live="polite" + aria-hidden={!bubblesFit || undefined} + style={{ visibility: bubblesFit ? undefined : 'hidden' }} onDoubleClick={event => event.stopPropagation()} data-openbitfun-component="agent-companion-desktop-pet" data-openbitfun-part="bubbles"> {displayTasks.map(task => { @@ -1251,12 +1288,16 @@ export const AgentCompanionDesktopPet: React.FC = () => { data-openbitfun-component="agent-companion-desktop-pet" data-openbitfun-part="hitbox" data-openbitfun-state={hasAttentionTask ? 'attention' : undefined}> task.state === 'attention')} + toolWaiting={tasks.some(task => task.state === 'waiting')} + livingLineState={livingLineState} dragDirection={dragDirection} action={!isDraggingPet ? mood === 'rest' && visibleTasks.some(task => task.state === 'error') ? 'failed' : reaction?.action ?? null : null} pet={pet} lookDirection={trackPetLook && !isDraggingPet ? lookDirection : null} + gaze={trackGrokGaze && !isDraggingPet ? grokGaze : null} nativePetdexSize petdexScale={PETDEX_DESKTOP_SCALE} petdexMaxSize={PET_MAX_DISPLAY_SIZE} diff --git a/src/web-ui/src/app/scenes/settings/pages/application/DesktopCardsSettingsSection.test.tsx b/src/web-ui/src/app/scenes/settings/pages/application/DesktopCardsSettingsSection.test.tsx index fe6d3fa14f..70db2b06f0 100644 --- a/src/web-ui/src/app/scenes/settings/pages/application/DesktopCardsSettingsSection.test.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/application/DesktopCardsSettingsSection.test.tsx @@ -191,6 +191,21 @@ it('renders nothing to pin and never reads cards while the feature is off', asyn expect(mocks.list).not.toHaveBeenCalled(); }); +it('removes the pin row when the host deletes a card', async () => { + mocks.list.mockResolvedValue({ + enabled: true, + desktopPinningSupported: true, + cards: [card('review'), card('ordinary')], + }); + await render(); + expect(pinRows()).toHaveLength(2); + await act(async () => mocks.changed.forEach(listener => listener({ cardId: 'review', deleted: true }))); + expect(pinRows()).toHaveLength(1); + expect(pinRows()[0].textContent).toContain('Card ordinary'); + await act(async () => pinSwitch(pinRows()[0]).click()); + expect(mocks.setDesktopPinned).toHaveBeenCalledWith('ordinary', true); +}); + it('reports a rejected pin change without flipping the switch', async () => { mocks.list.mockResolvedValue({ enabled: true, diff --git a/src/web-ui/src/app/scenes/settings/pages/application/LivingLineSettingsSection.test.tsx b/src/web-ui/src/app/scenes/settings/pages/application/LivingLineSettingsSection.test.tsx new file mode 100644 index 0000000000..fd89331169 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/pages/application/LivingLineSettingsSection.test.tsx @@ -0,0 +1,35 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import LivingLineSettingsSection from './LivingLineSettingsSection'; + +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })); +vi.mock('@/shared/notification-system', () => ({ useNotification: () => ({ error: vi.fn(), success: vi.fn() }) })); + +describe('LivingLine settings section', () => { + let root: Root; + let container: HTMLDivElement; + + beforeEach(() => { + localStorage.clear(); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + localStorage.clear(); + }); + + it('renders 4 color and 8 numeric controls plus reset', async () => { + await act(async () => root.render()); + expect(container.querySelectorAll('input[type="color"]').length).toBe(4); + expect(container.querySelectorAll('input[type="range"]').length).toBe(8); + expect(container.querySelectorAll('output').length).toBe(8); + expect(container.querySelector('button')?.textContent).toContain('reset'); + }); +}); \ No newline at end of file diff --git a/src/web-ui/src/app/scenes/settings/pages/application/LivingLineSettingsSection.tsx b/src/web-ui/src/app/scenes/settings/pages/application/LivingLineSettingsSection.tsx new file mode 100644 index 0000000000..11bfd0cb81 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/pages/application/LivingLineSettingsSection.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from 'react'; +import { Button } from '@openbitfun/ui'; +import { useI18n } from '@/infrastructure/i18n'; +import { useNotification } from '@/shared/notification-system'; +import { createLogger } from '@/shared/utils/logger'; +import { ConfigPageRow, ConfigPageSection } from '@/infrastructure/config/components/common'; +import { RANGES } from '@/flow_chat/components/living-line/settings'; +import { + DEFAULT_SETTINGS, + type LivingLineSettings, +} from '@/flow_chat/components/living-line/settings'; +import { + readLivingLineSettings, + saveLivingLineSettings, + subscribeLivingLineSettings, +} from '@/flow_chat/components/living-line/livingLineSettingsStore'; + +const log = createLogger('LivingLineSettingsSection'); + +const STEP_BY_FIELD: Partial> = { + lineWidth: 0.1, + flowLength: 1, + flowWidth: 0.1, + flowBlur: 0.5, + flowIntensity: 0.05, + lineGlowBlur: 0.5, + idlePeriodSeconds: 0.5, + workingPeriodSeconds: 0.1, +}; + +const COLOR_FIELDS: Array = ['lineColor', 'questionColor', 'errorColor', 'flowColor']; +const NUMBER_FIELDS: Array = [ + 'lineWidth', 'flowLength', 'flowWidth', 'flowBlur', 'flowIntensity', + 'lineGlowBlur', 'idlePeriodSeconds', 'workingPeriodSeconds', +]; + +export default function LivingLineSettingsSection() { + const { t } = useI18n('settings/runtime'); + const notification = useNotification(); + const [settings, setSettings] = useState(readLivingLineSettings); + + useEffect(() => subscribeLivingLineSettings(() => setSettings(readLivingLineSettings())), []); + + const update = (patch: Partial) => { + try { + setSettings(saveLivingLineSettings(patch)); + } catch (error) { + log.error('Failed to save LivingLine settings', error); + notification.error(t('messages.saveFailed')); + } + }; + + return ( + update(DEFAULT_SETTINGS)}> + {t('features.pet.livingLine.reset')} + + } + > + {COLOR_FIELDS.map(field => ( + + update({ [field]: event.target.value } as Partial)} + /> + + ))} + {NUMBER_FIELDS.map(field => { + const [min, max] = RANGES[field as keyof typeof RANGES]; + return ( + + update({ [field]: Number(event.target.value) } as Partial)} + /> + {settings[field]} + + ); + })} + + ); +} \ No newline at end of file diff --git a/src/web-ui/src/app/scenes/settings/pages/application/PetSettingsSection.tsx b/src/web-ui/src/app/scenes/settings/pages/application/PetSettingsSection.tsx index 12d9cc61e2..cb9ec2a11b 100644 --- a/src/web-ui/src/app/scenes/settings/pages/application/PetSettingsSection.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/application/PetSettingsSection.tsx @@ -17,6 +17,10 @@ import { type AgentCompanionPetPackage, } from '@/infrastructure/config/services/AgentCompanionPetService'; import { getPetSpriteLayout } from '@/infrastructure/config/services/agentCompanionPetSprite'; +import { isGrokLivePet } from '@/infrastructure/config/services/grokLivePetIdentity'; +import { isLivingLinePet } from '@/infrastructure/config/services/livingLinePetIdentity'; +import LivingLineSettingsSection from './LivingLineSettingsSection'; +import { DEFAULT_GROK_LIVE_SETTINGS, readGrokLiveSettings, saveGrokLiveSettings, subscribeGrokLiveSettings, type GrokLiveSettings } from '@/flow_chat/components/grok-live/grokLiveSettings'; import { aiExperienceConfigService, type AIExperienceSettings } from '@/infrastructure/config/services/AIExperienceConfigService'; import { confirmDanger } from '@/infrastructure/confirm-dialog'; import { useI18n } from '@/infrastructure/i18n'; @@ -28,6 +32,7 @@ import { captureConfirmationAnchor, Icon, IconButton, OverflowText, + Select, Switch, Tooltip } from '@openbitfun/ui'; @@ -38,10 +43,12 @@ const log = createLogger('PetSettingsSection'); const IS_TAURI_DESKTOP = typeof window !== 'undefined' && '__TAURI__' in window; function getPetPreviewStyle(pet: AgentCompanionPetPackage): React.CSSProperties { + const vectorPet = isGrokLivePet(pet) || isLivingLinePet(pet); return { - imageRendering: pet.source === 'preset' && pet.id === 'bitblob' ? 'auto' : undefined, + imageRendering: vectorPet || (pet.source === 'preset' && pet.id === 'bitblob') ? 'auto' : undefined, '--openbitfun-pet-preview-src': `url("${pet.previewSrc}")`, - backgroundSize: `800% ${getPetSpriteLayout(pet.spriteVersionNumber).rows * 100}%`, + backgroundSize: vectorPet ? 'contain' : `800% ${getPetSpriteLayout(pet.spriteVersionNumber).rows * 100}%`, + ...(vectorPet ? { backgroundPosition: 'center', animation: 'none' } : {}), } as React.CSSProperties; } @@ -70,6 +77,16 @@ const PetSettingsSection: React.FC<{ isActive?: boolean }> = ({ isActive = true const [companionPetImporting, setCompanionPetImporting] = useState(false); const [companionPetDeletingPath, setCompanionPetDeletingPath] = useState(null); + const [grokSettings, setGrokSettings] = useState(readGrokLiveSettings); + useEffect(() => subscribeGrokLiveSettings(() => setGrokSettings(readGrokLiveSettings())), []); + const updateGrok = (patch: Partial) => { + try { + setGrokSettings(saveGrokLiveSettings(patch)); + } catch (error) { + log.error('Failed to save GrokLive settings', error); + notification.error(t('messages.saveFailed')); + } + }; const reloadCompanionPets = useCallback(async () => { setCompanionPets(await companionPetInventory.read(listAgentCompanionPets)); @@ -388,6 +405,74 @@ const PetSettingsSection: React.FC<{ isActive?: boolean }> = ({ isActive = true
+ {isGrokLivePet(selectedCompanionPetReference) && ( + <> + + + + + updateGrok({ size: Number(event.target.value) })} /> + {grokSettings.size} + + + updateGrok({ bodyColor: value as GrokLiveSettings['bodyColor'] })} + options={[ + { value: 'black', label: t('features.pet.grok.bodyColor.black') }, + { value: 'darkGray', label: t('features.pet.grok.bodyColor.darkGray') }, + { value: 'gray', label: t('features.pet.grok.bodyColor.gray') }, + { value: 'custom', label: t('features.pet.grok.bodyColor.custom') }, + ]} aria-label={t('features.pet.grok.bodyColor')} /> + {grokSettings.bodyColor === 'custom' && ( + updateGrok({ bodyCustomColor: event.target.value })} /> + )} + + + updateGrok({ eyeCustomColor: event.target.value })} /> + )} + + {import.meta.env.DEV && ( + + updateGrok({ showStats: event.target.checked })} /> + + )} + + )} + {isLivingLinePet(selectedCompanionPetReference) && } )} diff --git a/src/web-ui/src/flow_chat/components/AgentCompanionPet.appearance.ts b/src/web-ui/src/flow_chat/components/AgentCompanionPet.appearance.ts index d12a3a51ae..9d39927d3b 100644 --- a/src/web-ui/src/flow_chat/components/AgentCompanionPet.appearance.ts +++ b/src/web-ui/src/flow_chat/components/AgentCompanionPet.appearance.ts @@ -11,6 +11,6 @@ export const agentCompanionPetAppearanceDescriptor: AppearanceSurfaceDescriptor ], facets: [ { id: 'mood', attribute: 'data-openbitfun-mood', values: ['rest', 'analyzing', 'waiting', 'working', 'hover', 'dragging'] }, - { id: 'layout', attribute: 'data-openbitfun-layout', values: ['default', 'center', 'stopRight', 'petdex'] }, + { id: 'layout', attribute: 'data-openbitfun-layout', values: ['default', 'center', 'stopRight', 'petdex', 'grok-live'] }, ], }; diff --git a/src/web-ui/src/flow_chat/components/AgentCompanionPet.test.tsx b/src/web-ui/src/flow_chat/components/AgentCompanionPet.test.tsx index 303f834a4d..5fca3cf6f3 100644 --- a/src/web-ui/src/flow_chat/components/AgentCompanionPet.test.tsx +++ b/src/web-ui/src/flow_chat/components/AgentCompanionPet.test.tsx @@ -14,6 +14,10 @@ const pet: AgentCompanionPetSelection = { id: 'sample', displayName: 'Sample', source: 'preset', packagePath: '/sample', spritesheetPath: '/sample/spritesheet.webp', spritesheetMimeType: 'image/webp', spriteVersionNumber: 2, }; +const livingLine: AgentCompanionPetSelection = { + id: 'living-line', displayName: 'LivingLine', source: 'preset', packagePath: '/agent-companion-pets/living-line', + spritesheetPath: '/agent-companion-pets/living-line/preview.svg', spritesheetMimeType: 'image/svg+xml', +}; let root: Root; let container: HTMLDivElement; @@ -50,6 +54,23 @@ function sprite() { return container.querySelector('[data-openbitfun-part="petdex"]')!; } +describe('AgentCompanionPet renderer routing', () => { + it('renders the independent LivingLine renderer and reports its rectangular frame', async () => { + const onSize = vi.fn(); + vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1)); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + + await act(async () => root.render( + , + )); + + expect(container.querySelector('.living-line-pet')?.getAttribute('data-openbitfun-state')).toBe('question'); + expect(container.querySelector('svg.living-line-art')?.getAttribute('viewBox')).toBe('0 0 256 144'); + expect(onSize).toHaveBeenCalledWith({ width: 288, height: 192 }); + expect(resolvePet).not.toHaveBeenCalled(); + }); +}); + describe('pet sprite renderer', () => { it('restores BitBlob atlas-only animation after look tracking expires', async () => { const bitblob = { ...pet, id: 'bitblob' }; diff --git a/src/web-ui/src/flow_chat/components/AgentCompanionPet.tsx b/src/web-ui/src/flow_chat/components/AgentCompanionPet.tsx index 59a25028ab..dbfb20aca0 100644 --- a/src/web-ui/src/flow_chat/components/AgentCompanionPet.tsx +++ b/src/web-ui/src/flow_chat/components/AgentCompanionPet.tsx @@ -26,6 +26,11 @@ import React, { useEffect, useRef, useState } from 'react'; import type { AgentCompanionMood } from '../utils/agentCompanionMood'; import type { AgentCompanionPetSelection } from '@/infrastructure/config/services/AIExperienceConfigService'; import { resolveAgentCompanionPet } from '@/infrastructure/config/services/AgentCompanionPetService'; +import { isGrokLivePet } from '@/infrastructure/config/services/grokLivePetIdentity'; +import { isLivingLinePet } from '@/infrastructure/config/services/livingLinePetIdentity'; +import { LivingLinePet } from './living-line/LivingLinePet'; +import type { LivingState } from './living-line/geometry'; +import { GrokLivePet } from './GrokLivePet'; import { getPetAnimationFrameCount, getPetLookFrame, getPetSpriteFrameSize } from '@/infrastructure/config/services/agentCompanionPetSprite'; import { createLogger } from '@/shared/utils/logger'; import { useI18n } from '@/infrastructure/i18n'; @@ -40,7 +45,11 @@ export interface AgentCompanionPetProps { /** Fits the native petdex frame into this box, preserving its aspect ratio. */ petdexMaxSize?: { width: number; height: number }; lookDirection?: number | null; + gaze?: [number, number] | null; action?: 'jumping' | 'waving' | 'failed' | null; + needsAttention?: boolean; + toolWaiting?: boolean; + livingLineState?: LivingState; dragDirection?: 'left' | 'right'; onPetFrameSizeChange?: (size: { width: number; height: number } | null) => void; } @@ -290,7 +299,7 @@ function pickIdleAction(prev: IdleAction | null): IdleAction { return pick; } -export const AgentCompanionPet: React.FC = ({ +const SpriteAgentCompanionPet: React.FC = ({ mood, className = '', pet = null, @@ -591,3 +600,11 @@ export const AgentCompanionPet: React.FC = ({ ); }; + +export const AgentCompanionPet: React.FC = (props) => { + if (isLivingLinePet(props.pet)) { + const state = props.livingLineState ?? (props.mood === 'waiting' ? 'question' : props.mood === 'working' || props.mood === 'analyzing' ? 'working' : 'idle'); + return ; + } + return isGrokLivePet(props.pet) ? : ; +}; diff --git a/src/web-ui/src/flow_chat/components/GrokLivePet.scss b/src/web-ui/src/flow_chat/components/GrokLivePet.scss new file mode 100644 index 0000000000..30b28f4327 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/GrokLivePet.scss @@ -0,0 +1,41 @@ +.openbitfun-grok-live { + position: relative; + display: block; + flex-shrink: 0; + overflow: visible; + pointer-events: none; + + &__stage { + position: relative; + width: 100%; + height: 100%; + background: transparent; + } + + &__stage > svg { + width: 100%; + height: 100%; + overflow: visible; + } + + &__stats { + position: absolute; + bottom: 100%; + left: 0; + width: max-content; + max-width: 280px; + padding: 3px; + font-family: var(--openbitfun-type-code-md-font-family); + font-size: var(--openbitfun-type-meta-font-size); + line-height: var(--openbitfun-type-support-line-height); + color: var(--openbitfun-color-content-on-dark); + background: var(--openbitfun-color-content-on-light); + pointer-events: none; + white-space: normal; + } +} + +:root[data-openbitfun-appearance-mode='dark'] .openbitfun-grok-live__stage > svg, +[data-openbitfun-appearance-mode='dark'] .openbitfun-grok-live__stage > svg { + filter: drop-shadow(0 0 2px var(--openbitfun-color-content-on-dark)); +} diff --git a/src/web-ui/src/flow_chat/components/GrokLivePet.test.tsx b/src/web-ui/src/flow_chat/components/GrokLivePet.test.tsx new file mode 100644 index 0000000000..7c3c12e484 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/GrokLivePet.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { GrokLivePet, grokStateForMood } from './GrokLivePet'; +import * as player from './grok-live/grok-player.js'; +import { readGrokLiveSettings, saveGrokLiveSettings, subscribeGrokLiveSettings } from './grok-live/grokLiveSettings'; +import { GROK_LIVE_PET } from '@/infrastructure/config/services/AgentCompanionPetService'; +import { isGrokLivePet } from '@/infrastructure/config/services/grokLivePetIdentity'; + +let host: HTMLDivElement; +let root: ReturnType; +const queuedFrames = new Map(); +let nextFrameId = 0; + +beforeEach(() => { + localStorage.clear(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })); + queuedFrames.clear(); + nextFrameId = 0; + vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { + const id = ++nextFrameId; + queuedFrames.set(id, callback); + return id; + })); + vi.stubGlobal('cancelAnimationFrame', vi.fn((id: number) => { queuedFrames.delete(id); })); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +it('renders a live black SVG sphere with two opaque white eyes and cleans up', async () => { + expect(isGrokLivePet(GROK_LIVE_PET)).toBe(true); + expect(isGrokLivePet({ ...GROK_LIVE_PET, source: 'user' })).toBe(false); + await act(async () => root.render()); + const stage = host.querySelector('.openbitfun-grok-live__stage')!; + const svg = stage.querySelector('svg[id^="gp"]')!; + expect(svg).toBeTruthy(); + const eyePath = svg.querySelector('path[fill="#ffffff"][d]:not([d=""])')!; + expect(eyePath).toBeTruthy(); + expect(eyePath.getAttribute('d')!.match(/M/g)?.length).toBeGreaterThanOrEqual(2); + expect(svg.querySelectorAll('path[fill="#000000"][d]:not([d=""])').length).toBeGreaterThan(0); + expect(svg.querySelector('path[mask]')).toBeNull(); + act(() => root.unmount()); + expect(host.querySelector('svg')).toBeNull(); + root = createRoot(host); +}); + +it('maps live task moods to existing recipe states', () => { + expect(grokStateForMood('rest')).toBe('idle'); + expect(grokStateForMood('analyzing')).toBe('thinking'); + expect(grokStateForMood('working')).toBe('coding'); + expect(grokStateForMood('waiting', true, true)).toBe('waiting'); + expect(grokStateForMood('waiting', false, true)).toBe('working'); + expect(grokStateForMood('dragging')).toBe('dragging'); +}); + +it('routes desktop gaze coordinates to the player without enabling webview pointer follow', async () => { + const create = vi.spyOn(player, 'createGrokBot'); + await act(async () => root.render()); + const bot = create.mock.results[0].value as player.GrokBot; + const setGaze = vi.spyOn(bot, 'setGaze'); + await act(async () => root.render()); + expect(setGaze).toHaveBeenCalledWith([0.25, -0.5]); + expect(create).toHaveBeenCalledWith(expect.any(HTMLElement), expect.objectContaining({ follow: false })); +}); + +it('updates live SVG frames for a task and pauses while hidden', async () => { + await act(async () => root.render()); + const svg = host.querySelector('svg[id^="gp"]')!; + const body = svg.querySelector('path[fill="#000000"][d]:not([d=""])')!; + const before = body.getAttribute('d'); + await act(async () => root.render()); + const start = performance.now(); + for (let frame = 1; frame <= 40; frame++) { + const next = queuedFrames.entries().next().value as [number, FrameRequestCallback] | undefined; + expect(next).toBeDefined(); + queuedFrames.delete(next![0]); + next![1](start + frame * 12); + } + expect(body.getAttribute('d')).not.toBe(before); + vi.spyOn(document, 'hidden', 'get').mockReturnValue(true); + document.dispatchEvent(new Event('visibilitychange')); + expect(queuedFrames.size).toBe(0); + vi.spyOn(document, 'hidden', 'get').mockReturnValue(false); + document.dispatchEvent(new Event('visibilitychange')); + expect(queuedFrames.size).toBeGreaterThan(0); + act(() => root.unmount()); + expect(queuedFrames.size).toBe(0); + root = createRoot(host); +}); + +it('returns to the latest mood after failure and keeps accepting mood updates', async () => { + vi.useFakeTimers(); + const create = vi.spyOn(player, 'createGrokBot'); + await act(async () => root.render()); + const bot = create.mock.results[0].value as player.GrokBot; + expect(bot.state).toBe('failed'); + await act(async () => root.render()); + expect(bot.state).toBe('failed'); + act(() => vi.advanceTimersByTime(1650)); + expect(bot.state).toBe('thinking'); + await act(async () => root.render()); + expect(bot.state).toBe('coding'); + expect(create).toHaveBeenCalledTimes(1); +}); + +it('keeps the fallback when synchronous initialization fails', async () => { + vi.spyOn(player, 'createGrokBot').mockImplementation(() => { throw new Error('test initialization failure'); }); + await act(async () => root.render()); + expect(host.querySelector('.openbitfun-grok-live__fallback')).toBeTruthy(); + expect(host.querySelector('[aria-label="GrokLive (animation unavailable)"]')).toBeTruthy(); +}); + +it('does not follow the pointer or schedule frames with reduced motion', async () => { + vi.stubGlobal('matchMedia', () => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })); + const listener = vi.spyOn(window, 'addEventListener'); + await act(async () => root.render()); + expect(queuedFrames.size).toBe(0); + expect(listener.mock.calls.some(([name]) => name === 'pointermove')).toBe(false); +}); + +it('persists settings and notifies live subscribers', () => { + const onChange = vi.fn(); + const unsubscribe = subscribeGrokLiveSettings(onChange); + saveGrokLiveSettings({ fps: '60', follow: false, size: 112 }); + expect(readGrokLiveSettings()).toMatchObject({ fps: '60', follow: false, size: 112 }); + expect(onChange).toHaveBeenCalled(); + unsubscribe(); +}); diff --git a/src/web-ui/src/flow_chat/components/GrokLivePet.tsx b/src/web-ui/src/flow_chat/components/GrokLivePet.tsx new file mode 100644 index 0000000000..7116e52e75 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/GrokLivePet.tsx @@ -0,0 +1,185 @@ +import React, { useEffect, useRef, useState } from 'react'; +import recipes from './grok-live/grok-recipes.json'; +import { createGrokBot, type GrokBot } from './grok-live/grok-player.js'; +import { readGrokLiveSettings, subscribeGrokLiveSettings, resolveGrokBodyColorHex, resolveGrokEyeColorHex, hexToRgb01 } from './grok-live/grokLiveSettings'; +import type { AgentCompanionPetProps } from './AgentCompanionPet'; +import './GrokLivePet.scss'; + +type Props = Pick & { + needsAttention?: boolean; + toolWaiting?: boolean; + gaze?: [number, number] | null; +}; +type FrameSample = { intervalMs: number; engineMs: number; domMs: number }; + +export function grokStateForMood(mood: Props['mood'], needsAttention = false, toolWaiting = false): string { + switch (mood) { + case 'rest': return 'idle'; + case 'analyzing': return 'thinking'; + case 'working': return 'coding'; + case 'waiting': return needsAttention ? 'waiting' : toolWaiting ? 'working' : 'waiting'; + case 'hover': return 'listening'; + case 'dragging': return 'dragging'; + } +} + +function applyBaseState(bot: GrokBot, state: string, idleMotion: boolean): void { + bot.setResting(false); + bot.setState(state); + if (state === 'idle' && !idleMotion) bot.setResting(true); +} + +const ACTION_STATES = { jumping: 'waking', waving: 'finished', failed: 'failed' } as const; + +/** One Grok engine per mounted pet; state changes never rerender SVG through React. */ +export const GrokLivePet: React.FC = ({ + mood, action = null, className = '', nativePetdexSize = false, gaze, + onPetFrameSizeChange, needsAttention = false, toolWaiting = false, +}) => { + const hasExternalGaze = gaze !== undefined; + const [settings, setSettings] = useState(readGrokLiveSettings); + const [reducedMotion, setReducedMotion] = useState(() => + typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches); + const [ready, setReady] = useState(false); + const [error, setError] = useState(false); + const [stats, setStats] = useState(''); + const stageRef = useRef(null); + const botRef = useRef(null); + const actionRef = useRef(action); + const transientActiveRef = useRef(false); + const baseState = grokStateForMood(mood, needsAttention, toolWaiting); + const baseRef = useRef(baseState); + baseRef.current = baseState; + actionRef.current = action; + + useEffect(() => subscribeGrokLiveSettings(() => setSettings(readGrokLiveSettings())), []); + useEffect(() => { + if (typeof window.matchMedia !== 'function') return; + const media = window.matchMedia('(prefers-reduced-motion: reduce)'); + const onChange = () => setReducedMotion(media.matches); + media.addEventListener('change', onChange); + return () => media.removeEventListener('change', onChange); + }, []); + useEffect(() => { + onPetFrameSizeChange?.(nativePetdexSize ? { width: settings.size, height: settings.size } : null); + return () => onPetFrameSizeChange?.(null); + }, [nativePetdexSize, onPetFrameSizeChange, settings.size]); + + useEffect(() => { + if (!stageRef.current) return; + let disposed = false; + let start = performance.now(); + const samples: FrameSample[] = []; + setReady(false); + setError(false); + setStats(''); + const maxFps = settings.fps === '60' ? 60 : 120; + let bot: GrokBot; + try { + bot = createGrokBot(stageRef.current, { + data: recipes, size: 320, maxFps, state: baseRef.current, + follow: settings.follow && !hasExternalGaze && !reducedMotion, reducedMotion, + theme: { ink: hexToRgb01(resolveGrokBodyColorHex(settings)), spot: hexToRgb01(resolveGrokEyeColorHex(settings)), cutout: false }, + onFrame: import.meta.env.DEV && settings.showStats ? sample => { + if (sample.intervalMs == null || !Number.isFinite(sample.intervalMs)) return; + samples.push(sample); + const now = performance.now(); + if (now - start < 2000) return; + const intervals = samples.map(s => s.intervalMs).sort((a, b) => a - b); + const mean = (key: keyof FrameSample) => samples.reduce((sum, s) => sum + s[key], 0) / samples.length; + const avg = mean('intervalMs'); + const dropped = samples.filter(s => s.intervalMs > 1.5 * 1000 / maxFps).length / samples.length; + setStats(`JS SVG ${Math.round(1000 / avg)} FPS | avg ${avg.toFixed(1)}ms | p95 ${intervals[Math.ceil(.95 * intervals.length) - 1].toFixed(1)}ms | target gaps ${(100 * dropped).toFixed(0)}% | engine ${mean('engineMs').toFixed(2)}ms | DOM ${mean('domMs').toFixed(2)}ms`); + samples.length = 0; + start = now; + } : undefined, + }); + } catch { + setError(true); + return; + } + botRef.current = bot; + void bot.ready.then(() => { + if (disposed) return; + if (bot.error) { setError(true); return; } + if (!transientActiveRef.current) applyBaseState(bot, baseRef.current, readGrokLiveSettings().idleMotion); + setReady(true); + }).catch(() => { if (!disposed) setError(true); }); + return () => { + disposed = true; + if (botRef.current === bot) botRef.current = null; + bot.destroy(); + }; + // The engine is rebuilt only for frame policy, motion preference or debug instrumentation. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [settings.fps, settings.showStats, reducedMotion]); + + useEffect(() => { + botRef.current?.setFollow(settings.follow && !hasExternalGaze && !reducedMotion); + }, [settings.follow, reducedMotion, hasExternalGaze, settings.fps, settings.showStats]); + useEffect(() => { + if (!settings.follow || reducedMotion || !hasExternalGaze) return; + botRef.current?.setGaze(gaze ?? null); + }, [gaze, settings.follow, reducedMotion, hasExternalGaze, settings.fps, settings.showStats]); + useEffect(() => { + const ink = hexToRgb01(resolveGrokBodyColorHex(settings)); + botRef.current?.setGlow(settings.glow ? { + inner: ink.map(v => Math.min(1, v + 0.16)), + mid: ink.map(v => Math.max(0, v - 0.05)), + rim: ink.map(v => Math.max(0, v - 0.14)), + halo: 0, + } : null); + }, [settings.glow, settings.bodyColor, settings.bodyCustomColor, settings.fps, settings.showStats, reducedMotion]); + useEffect(() => { + if ((!action || !transientActiveRef.current) && botRef.current) { + applyBaseState(botRef.current, baseRef.current, settings.idleMotion); + } + }, [baseState, action, settings.idleMotion]); + useEffect(() => { + if (!action) return; + const bot = botRef.current; + if (!bot) return; + transientActiveRef.current = true; + bot.setResting(false); + bot.setState(ACTION_STATES[action]); + const timer = window.setTimeout(() => { + transientActiveRef.current = false; + if (bot === botRef.current) applyBaseState(bot, baseRef.current, readGrokLiveSettings().idleMotion); + }, 1650); + return () => { + transientActiveRef.current = false; + window.clearTimeout(timer); + }; + }, [action, settings.fps, settings.showStats, reducedMotion]); + useEffect(() => { + if (!settings.idleMotion || baseState !== 'idle' || reducedMotion) return; + let timer = 0; + let stopped = false; + const schedule = () => { + timer = window.setTimeout(() => { + if (stopped) return; + if (!document.hidden && !actionRef.current) botRef.current?.blink(); + schedule(); + }, 3500 + Math.random() * 5000); + }; + schedule(); + return () => { stopped = true; window.clearTimeout(timer); }; + }, [baseState, settings.idleMotion, reducedMotion]); + + return ( +
+
+ {!ready && ()} +
+ {import.meta.env.DEV && settings.showStats && stats && ({stats})} +
+ ); +}; diff --git a/src/web-ui/src/flow_chat/components/grok-live/SOURCE.txt b/src/web-ui/src/flow_chat/components/grok-live/SOURCE.txt new file mode 100644 index 0000000000..8795257b89 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/grok-live/SOURCE.txt @@ -0,0 +1,5 @@ +Local personal-research integration. Do not redistribute the extracted recipes or original character design without rights clearance. +Source: https://github.com/ArvinYin1/grokbot-animation-anatomy +Files: js/vendor/grok-player.js (browser ESM port), js/vendor/grok-recipes.json (extracted app data). +Source repository README states MIT, but separately states that the underlying Grok design and extracted recipes remain the property of their original owners and are for study/research. +Local changes to grok-player.js: visibility pause/resume, frame instrumentation, and RAF cadence. diff --git a/src/web-ui/src/flow_chat/components/grok-live/grok-player.d.ts b/src/web-ui/src/flow_chat/components/grok-live/grok-player.d.ts new file mode 100644 index 0000000000..52818e9242 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/grok-live/grok-player.d.ts @@ -0,0 +1,27 @@ +/** Browser ESM port from ArvinYin1/grokbot-animation-anatomy; see SOURCE.txt. */ +export interface GrokBot { + readonly ready: Promise; + readonly error: unknown; + readonly state: string; + readonly el: SVGSVGElement | null; + setState(state: string): void; + setFollow(value: boolean): void; + setGaze(value: [number, number] | null): void; + setResting(value: boolean): void; + setGlow(spec: { inner: number[]; mid: number[]; rim: number[]; halo: number } | null): void; + setExpression(expression: string, options?: { throughBlink?: boolean }): void; + blink(): void; + look(direction: string): void; + poke(kind: string, turns?: number): void; + destroy(): void; + } + export function createGrokBot(container: HTMLElement, opts?: { + data?: object; + size?: number; + maxFps?: number; + state?: string; + follow?: boolean; + reducedMotion?: boolean; + theme?: { ink: number[]; spot: number[]; cutout: boolean }; + onFrame?: (sample: { intervalMs: number; engineMs: number; domMs: number }) => void; + }): GrokBot; diff --git a/src/web-ui/src/flow_chat/components/grok-live/grok-player.js b/src/web-ui/src/flow_chat/components/grok-live/grok-player.js new file mode 100644 index 0000000000..4aa20a9a02 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/grok-live/grok-player.js @@ -0,0 +1,1931 @@ +// grok-player.js — Grok Bot 头像动画引擎的浏览器 ESM 播放器。 +// +// 本文件是按逆向语义文档对原版引擎(grokbot renderer bundle)的忠实移植。 +// 关键函数旁标注了原始混淆名(如 `// Qa`),参数、曲线、求值顺序均与原版一致。 +// 数据(clips/states/表情/形状度量/常量)全部来自 grok-recipes.json,不在此硬编码。 +// +// 用法: +// import { createGrokBot } from "./vendor/grok-player.js"; +// const bot = createGrokBot(el, { size: 320, state: "idle", seed: 1, follow: true }); +// bot.setState("thinking"); bot.destroy(); +// +// 已移植:状态机 Nx、clip 求值 vt/Qa、bridge bx/rest 帧 ul/wx、表情机 Bb(含 +// 三条过渡路径与 lidMorph)、眨眼 gx/ab、注视 al/ex、look/poke、morph 行为器 cx、 +// morph 覆盖层 yE + 12 种生成器、眼睛 SDF 管线(cT/UM/ST/RM)、sphere 身体 wT/vT、 +// glow TT(49 stops + halo)、morph 身体轮廓混合(gM/kM/SM/TE)。 +// 未移植:非 sphere 身体形状(Bodymoves_A=clover、Rotation_*=cloud 会回退 sphere +// 并警告一次)、proof 线框调试层、多身体 setBody 交叉淡化的轮廓混合(PE)、 +// pencil morph 的身体环轮廓 VT(回退为圆形环 $h,其余 pencil 动画完整)。 + +const TAU = Math.PI * 2; // ut / CT +const DEG = Math.PI / 180; // vn + +// ---------- 基础数学 ---------- +const le = (e, t, n) => Math.min(n, Math.max(t, e)); // le +const Jo = e => { const t = le(e, 0, 1); return t * t * (3 - 2 * t); }; // Jo (smoothstep) +const Qo = e => e < .5 ? 4 * e * e * e : 1 - Math.pow(-2 * e + 2, 3) / 2; // Qo easeInOutCubic +const Ye = e => 1 - Math.pow(1 - e, 3); // Ye easeOutCubic +const Es = e => 1 + 2.70158 * Math.pow(e - 1, 3) + 1.70158 * Math.pow(e - 1, 2); // Es easeOutBack +const eb = Jo; // eb 同为 smoothstep +const ma = (e, t, n) => { const r = le((n - e) / (t - e), 0, 1); return r * r * (3 - 2 * r); }; // ma +const Bt = e => (e % 1 + 1) % 1; // Bt +const fo = (e, t, n) => e <= t || e >= n ? 0 : Math.sin(Math.PI * (e - t) / (n - t)) ** 2; // fo +const cd = e => e - TAU * Math.round(e / TAU); // cd +const Dr = e => { const t = e % TAU; return t < 0 ? t + TAU : t; }; // Dr +const ad = (e, t, n) => [e[0] + (t[0] - e[0]) * n, e[1] + (t[1] - e[1]) * n]; // ad +const ud = (e, t) => { const n = t[0] - e[0], r = t[1] - e[1]; return n * n + r * r; }; // ud +function QM(e, t, n) { // QM:点到线段距离 + const r = n[0] - t[0], o = n[1] - t[1], s = e[0] - t[0], i = e[1] - t[1], + a = r * r + o * o, c = a > 0 ? le((s * r + i * o) / a, 0, 1) : 0, u = s - r * c, l = i - o * c; + return Math.sqrt(u * u + l * l); +} + +// ---------- 贝塞尔 easing(Qa:牛顿迭代解 x,再取 y) ---------- +const el = (e, t, n) => { const r = 1 - e; return 3 * r * r * e * t + 3 * r * e * e * n + e * e * e; }; +const sb = (e, t, n) => { const r = 1 - e; return 3 * r * r * t + 6 * r * e * (n - t) + 3 * e * e * (1 - n); }; +function Qa(e, t) { + const [n, r, o, s] = t; let i = e; + for (let a = 0; a < 8; a++) { + const c = el(i, n, o) - e, u = sb(i, n, o); + if (Math.abs(c) < 1e-6 || Math.abs(u) < 1e-6) break; + i = le(i - c / u, 0, 1); + } + return el(i, r, s); +} + +// ---------- 随机数 tc + 洗牌 Lx/hl ---------- +function tc(e) { + let t = e | 0; + return () => { + t = t + 1831565813 | 0; + let n = Math.imul(t ^ t >>> 15, t | 1); + n ^= n + Math.imul(n ^ n >>> 7, n | 61); + return ((n ^ n >>> 14) >>> 0) / 4294967296; + }; +} +const hl = (e, t, n) => { const r = e[t], o = e[n]; e[t] = o, e[n] = r; }; +function Lx(e, t, n) { + const r = [...e]; + for (let s = r.length - 1; s > 0; s -= 1) hl(r, s, Math.floor(t() * (s + 1))); + const o = r.findIndex(s => s !== n); + return o > 0 && hl(r, 0, o), r; +} + +// ---------- 四元数 ---------- +function ra(e, t) { // ra:哈密顿积 + return [e[0] * t[0] - e[1] * t[1] - e[2] * t[2] - e[3] * t[3], + e[0] * t[1] + e[1] * t[0] + e[2] * t[3] - e[3] * t[2], + e[0] * t[2] - e[1] * t[3] + e[2] * t[0] + e[3] * t[1], + e[0] * t[3] + e[1] * t[2] - e[2] * t[1] + e[3] * t[0]]; +} +const Po = (e, t) => { const n = Math.sin(t / 2); return [Math.cos(t / 2), e[0] * n, e[1] * n, e[2] * n]; }; // Po 轴角 +const cb = e => { const t = Math.hypot(e[0], e[1], e[2], e[3]) || 1; return [e[0] / t, e[1] / t, e[2] / t, e[3] / t]; }; +function ir(e) { // ir:四元数→3×3 + const [t, n, r, o] = e; + return [1 - 2 * (r * r + o * o), 2 * (n * r - t * o), 2 * (n * o + t * r), + 2 * (n * r + t * o), 1 - 2 * (n * n + o * o), 2 * (r * o - t * n), + 2 * (n * o - t * r), 2 * (r * o + t * n), 1 - 2 * (n * n + r * r)]; +} +const lb = (e, t, n) => ra(Po([0, 0, 1], n), ra(Po([0, 1, 0], t), Po([1, 0, 0], e))); // lb:欧拉(rad)→quat,Qz·Qy·Qx +const Et = (e, t) => [ // Et:3×3 乘向量 + e[0] * t[0] + e[1] * t[1] + e[2] * t[2], + e[3] * t[0] + e[4] * t[1] + e[5] * t[2], + e[6] * t[0] + e[7] * t[1] + e[8] * t[2]]; + +// ---------- 弹簧(半隐式欧拉,120Hz 子步) ---------- +const To = e => ({ x: e, v: 0, t: e }); // To +const Eo = (e, t, n, r) => { // Eo:v+=(-2ζωv-ω²(x-t))dt + e.v += (-2 * n * t * e.v - t * t * (e.x - e.t)) * r; e.x += e.v * r; + (!Number.isFinite(e.x) || !Number.isFinite(e.v)) && (e.x = e.t, e.v = 0); +}; +const Jw = 1 / 120, Bf = e => Math.max(1, Math.ceil(e / Jw)); // Jw / Bf + +// ============================================================ +// 眼睛描述符几何(xb/Gf/Pr/ec/oa/Nb/Ob/Lb/rl/Fb/zb/Db/ol) +// 描述符:{off:[x,y], tilt(rad), wid, lidY, oval:[rx,ry], ovalMix, pts(21×2), path(18), fillRing} +// ============================================================ + +// xb:二次贝塞尔 p0/p1/p2 → 3 段三次贝塞尔的扁平数组(每段 6:锚点2 + 出柄2 + 入柄2) +function xb({ p0: e, p1: t, p2: n, isArc: r }, KB) { + const [o, s] = e, [i, a] = t, [c, u] = n, l = new Array(KB).fill(0); + const d = (y, w) => { l[y * 6] = w[0], l[y * 6 + 1] = w[1]; }, + f = (y, w) => { l[y * 6 + 2] = w[0] - l[y * 6], l[y * 6 + 3] = w[1] - l[y * 6 + 1]; }, + h = (y, w) => { l[y * 6 + 4] = w[0] - l[y * 6], l[y * 6 + 5] = w[1] - l[y * 6 + 1]; }; + if (r) { // 圆弧拟合:过三点作圆 → 两段三次贝塞尔 + const y = 2 * (o * (a - u) + i * (u - s) + c * (s - a)); + if (Math.abs(y) > 1e-9) { + const S = o * o + s * s, b = i * i + a * a, v = c * c + u * u, + T = (S * (a - u) + b * (u - s) + v * (s - a)) / y, A = (S * (c - i) + b * (o - c) + v * (i - o)) / y, + F = Math.hypot(o - T, s - A), N = Math.atan2(s - A, o - T), R2 = Math.atan2(u - A, c - T), + B2 = Math.atan2(a - A, i - T), E = TAU, I = (R2 - N + E) % E, + O = (B2 - N + E) % E <= I ? I : I - E, H = K => [T + F * Math.cos(K), A + F * Math.sin(K)], + L = K => [-Math.sin(K), Math.cos(K)], j = O / 2, J = 4 / 3 * Math.tan(j / 4) * F, fe = [N, N + j, N + O]; + for (let K = 0; K < 3; K++) { + const te = H(fe[K]), Z = L(fe[K]); + d(K, te), K > 0 && f(K, [te[0] - J * Z[0], te[1] - J * Z[1]]), K < 2 && h(K, [te[0] + J * Z[0], te[1] + J * Z[1]]); + } + return l; + } + const w = [(o + c) / 2, (s + u) / 2]; // 退化(共线):二次曲线中点细分 + return d(0, [o, s]), d(1, w), d(2, [c, u]), h(0, [o + (w[0] - o) / 3, s + (w[1] - s) / 3]), + f(1, [w[0] - (w[0] - o) / 3, w[1] - (w[1] - s) / 3]), h(1, [w[0] + (c - w[0]) / 3, w[1] + (u - w[1]) / 3]), + f(2, [c - (c - w[0]) / 3, u - (u - w[1]) / 3]), l; + } + const p = [(o + i) / 2, (s + a) / 2], g = [(i + c) / 2, (a + u) / 2], m = [(p[0] + g[0]) / 2, (p[1] + g[1]) / 2]; + return d(0, [o, s]), d(1, m), d(2, [c, u]), h(0, [o + 2 / 3 * (p[0] - o), s + 2 / 3 * (p[1] - o)]), + f(1, [m[0] - 2 / 3 * (p[0] - m[0]), m[1] - 2 / 3 * (p[1] - m[1])]), + h(1, [m[0] + 2 / 3 * (g[0] - m[0]), m[1] + 2 / 3 * (g[1] - m[1])]), + f(2, [c + 2 / 3 * (g[0] - c), u + 2 / 3 * (g[1] - u)]), l; +} +const uo = (e, t) => [e[t * 6], e[t * 6 + 1]]; // uo +const lo = (e, t, n) => { const r = n ? 4 : 2; return [e[t * 6] + e[t * 6 + r], e[t * 6 + 1] + e[t * 6 + r + 1]]; }; // lo +// Gf:path 扁平数组 → 脊线采样点(yb=21 点) +function Gf(e, YB) { + const t = YB + 1 >> 1, n = [], r = (o, s, i, a, c) => { + for (let u = c; u < t; u++) { + const l = u / (t - 1), d = 1 - l, f = d * d * d, h = 3 * d * d * l, p = 3 * d * l * l, g = l * l * l; + n.push([f * o[0] + h * s[0] + p * i[0] + g * a[0], f * o[1] + h * s[1] + p * i[1] + g * a[1]]); + } + }; + return r(uo(e, 0), lo(e, 0, !0), lo(e, 1, !1), uo(e, 1), 0), r(uo(e, 1), lo(e, 1, !0), lo(e, 2, !1), uo(e, 2), 1), n; +} +// Pr/ec/oa/Nb:构建/重建眼睛描述符 +const Pr = (e, t, n, r, o, G) => { const s = xb(r, G.kb); return { off: e, tilt: t, wid: n, lidY: 1, oval: o, ovalMix: 0, pts: Gf(s, G.yb), path: s, fillRing: null }; }; +function ec(e, t, n, r, o, G) { + const s = Math.max(0, (t - e) / 2), i = n * DEG, a = Math.sin(i) * s, c = Math.cos(i) * s; + return Pr(r, o, e / 2, { p0: [-a, -c], p1: [0, 0], p2: [a, c], isArc: !1 }, [0, 0], G); +} +const oa = (e, t, n, r, G) => ec(Math.max(e, 1e-4), Math.max(t, e), 0, r, n, G); // oa +const Nb = (e, t, n, r, G) => t >= e ? oa(e, t, 0, [n, r], G) : oa(t, e, Math.PI / 2, [n, r], G); // Nb + +const jt = e => e.oval[0] > 1e-4 && e.oval[1] > 1e-4; // jt:椭圆眼 +function ts(e) { // ts:弧脊眼(脊线偏离弦 > max(.012, wid*.2)) + if (jt(e) || e.pts.length < 3) return !1; + const t = e.pts[0], n = e.pts[e.pts.length - 1], r = n[0] - t[0], o = n[1] - t[1], s = r * r + o * o; + if (s < 1e-8) return !1; + let i = 0; + for (const a of e.pts) { + const c = ((a[0] - t[0]) * r + (a[1] - t[1]) * o) / s, u = t[0] + c * r, l = t[1] + c * o, d = Math.hypot(a[0] - u, a[1] - l); + d > i && (i = d); + } + return i > Math.max(.012, e.wid * .2); +} +const Zn = (e, t, n) => e + (t - e) * n; // Zn +const Ao = (e, t, n) => [Zn(e[0], t[0], n), Zn(e[1], t[1], n)]; // Ao +function nl(e, t, n) { // nl:脊线点列重采样取值 + if (e.length === 0) return [0, 0]; + if (e.length === 1 || n <= 1) return e[0]; + const r = t / (n - 1) * (e.length - 1), o = Math.min(Math.floor(r), e.length - 2); + return Ao(e[o], e[o + 1], r - o); +} +// _b:spine 路径插值 +function _b(e, t, n, G) { + const r = { off: Ao(e.off, t.off, n), tilt: Zn(e.tilt, t.tilt, n), wid: Zn(e.wid, t.wid, n), lidY: Zn(e.lidY, t.lidY, n), oval: Ao(e.oval, t.oval, n), ovalMix: 0, fillRing: null }; + if (e.path && t.path && e.path.length === t.path.length) { + const i = t.path, a = e.path.map((c, u) => Zn(c, i[u], n)); + return { ...r, path: a, pts: Gf(a, G.yb) }; + } + const o = Math.max(e.pts.length, t.pts.length, 2), s = []; + for (let i = 0; i < o; i++) s.push(Ao(nl(e.pts, i, o), nl(t.pts, i, o), n)); + return { ...r, path: null, pts: s }; +} +// ns:眼睛→椭圆参数 {cx,cy,ang,halfLen,halfW} +function ns(e) { + if (jt(e)) { + const [o, s] = e.oval; + return { cx: e.off[0], cy: e.off[1], ang: o >= s ? Math.PI / 2 : 0, halfLen: Math.max(o, s), halfW: Math.min(o, s) }; + } + const t = e.wid; + if (e.pts.length < 2) return { cx: e.off[0], cy: e.off[1], ang: e.tilt, halfLen: t, halfW: t }; + const n = e.pts[0], r = e.pts[e.pts.length - 1]; + return { cx: e.off[0] + (n[0] + r[0]) / 2, cy: e.off[1] + (n[1] + r[1]) / 2, ang: Math.atan2(r[0] - n[0], r[1] - n[1]) + e.tilt, halfLen: Math.hypot(r[0] - n[0], r[1] - n[1]) / 2 + t, halfW: t }; +} +function Wf(e, t) { // Wf:角度差 wrap 到 ±π/2 + let n = t.ang - e.ang; + for (; n > Math.PI / 2;) n -= Math.PI; + for (; n < -Math.PI / 2;) n += Math.PI; + return n; +} +function Cb(e, t, n) { // Cb:oval fillRing(圆→椭圆 48 点环) + const o = Math.max(0, e - t), s = le(n, 0, 1), i = []; + for (let a = 0; a < 48; a++) { + const c = a / 48 * TAU, u = Math.cos(c), l = Math.sin(c), d = t * u, f = e * l, h = t / Math.max(Math.abs(u), 1e-6); + let p; + if (h * Math.abs(l) <= o + 1e-6 && Number.isFinite(h)) p = h; + else { const w = (l >= 0 ? 1 : -1) * o * l, S = Math.max(0, w * w - o * o + t * t); p = w + Math.sqrt(S); } + const g = p * u, m = p * l; + i.push([g + (d - g) * s, m + (f - m) * s]); + } + return i; +} +const wt = e => ({ ...e, ovalMix: 0 }); // wt +const nt = (e, t) => [t(e[0], 0), t(e[1], 1)]; // nt +// Rb:oval 过渡路径 +function Rb(e, t, n, G) { + if (n <= 1e-4) return wt(e); + if (n >= .995) return wt(t); + const r = ns(e), o = ns(t), s = Wf(r, o), i = jt(t), a = i ? 1 - (1 - n) ** 3 : n * n * n, + c = r.ang + s * n, u = Math.max(r.halfLen + (o.halfLen - r.halfLen) * n, 1e-4), + l = Math.max(r.halfW + (o.halfW - r.halfW) * n, 1e-4), d = jt(e) ? 1 : 0, f = d + ((i ? 1 : 0) - d) * a, + h = oa(2 * l, 2 * u, c, [r.cx + (o.cx - r.cx) * n, r.cy + (o.cy - r.cy) * n], G), p = f > 1e-4; + return { ...h, oval: p ? [l, u] : h.oval, ovalMix: p ? f : 0, fillRing: Cb(u, l, f) }; +} +// $b:stadium 过渡路径 +function $b(e, t, n, G) { + if (n >= 1 - 1e-4) return wt(t); + const r = ns(e), o = ns(t), s = r.ang + Wf(r, o) * n, i = Math.max(1e-4, r.halfW + (o.halfW - r.halfW) * n), + a = Math.max(0, r.halfLen - r.halfW) * (1 - n) + Math.max(0, o.halfLen - o.halfW) * n, + c = [r.cx + (o.cx - r.cx) * n, r.cy + (o.cy - r.cy) * n]; + return ec(2 * i, 2 * (a + i), s / DEG, c, 0, G); +} +// Ob/Lb:包围盒 +function Ob(e) { + const t = jt(e); + if (!(e.wid > 0) && !t) return null; + const n = e.lidY, r = e.tilt / DEG; + if (t) return { width: 2 * e.oval[0], height: 2 * e.oval[1] * n, tilt: r, cx: e.off[0], cy: e.off[1] }; + if (e.pts.length < 2) return null; + const o = e.pts[0], s = e.pts[e.pts.length - 1], i = s[0] - o[0], a = s[1] - o[1], c = Math.hypot(i, a), u = 2 * e.wid; + return { width: u, height: (c + u) * n, tilt: Math.atan2(i, a) / DEG + r, cx: e.off[0] + (o[0] + s[0]) / 2, cy: e.off[1] + (o[1] + s[1]) / 2 * n }; +} +function Lb(e, XR) { + const t = Ob(e); + if (t == null) return { w: XR, h: .57, cx: e.off[0], cy: e.off[1] }; + const n = t.tilt * DEG, r = Math.abs(Math.cos(n)), o = Math.abs(Math.sin(n)); + return { w: t.width * r + t.height * o, h: t.width * o + t.height * r, cx: t.cx, cy: t.cy }; +} +// rl:lid 量 t 挤压眼睛(眨眼/lids track 的最终施加) +function rl(e, t, G) { + if (t <= 0) return e; + const n = Math.min(1, t), r = Lb(e, G.xr), o = Math.max(r.w, 1e-4), s = Math.max(r.h, o), i = o * G.Ab; + return Nb(o, s + (i - s) * n, r.cx, r.cy, G); +} +const Fb = e => ({ ...e, wid: 0, oval: [0, 0] }); // Fb:全闭眼 +function zb(e, G) { // zb:眼睛纵向范围(lid 支点) + let t, n; + if (jt(e)) t = -e.oval[1], n = e.oval[1]; + else { + let r = 1e9, o = -1e9; + for (const s of e.pts) s[1] < r && (r = s[1]), s[1] > o && (o = s[1]); + t = r - e.wid, n = o + e.wid; + } + return t + (1 - G.Pb) * (n - t); +} +function Db(e) { // Db:脊线旋转归一(弦变竖直,角度并入 tilt) + if (e.pts.length < 2) return e; + const t = e.pts[0], n = e.pts[e.pts.length - 1], r = n[0] - t[0], o = n[1] - t[1]; + if (Math.abs(o) < Math.abs(r)) return e; + const s = Math.atan2(r, o); + if (Math.abs(s) < 1e-5) return e; + const i = Math.cos(s), a = Math.sin(s); + return { ...e, tilt: e.tilt + s, pts: e.pts.map(c => [c[0] * i - c[1] * a, c[0] * a + c[1] * i]) }; +} +function ol(e, t, G) { // ol:lidMorph 的闭合量施加(lidY 压缩 + off 偏移) + if (t === 0) return e; + const n = Db(e), r = Math.max(G.Eb, 1 - t), o = zb(n, G) * (1 - r), s = Math.cos(-n.tilt), i = Math.sin(-n.tilt); + return { ...n, off: [n.off[0] - i * o, n.off[1] + s * o], lidY: n.lidY * r }; +} + +// ============================================================ +// 表情机 Bb(Face):眨眼 / morph(.28s) / lidMorph(.35s) / inspect 加权 +// ============================================================ +const sl = e => e === "inspecting" ? 1 : 0; // sl + +function createFace(G) { // Bb + const bn = G.expressions, bb = G.blinkBlocking, wb = G.expressionAliases, gb = G.expressionNames; + const Lr = e => bb.has(e); // Lr + const Sb = e => gb.has(e); // Sb + const Mb = e => { const t = e.trim().toLowerCase(), n = wb.get(t) ?? t; return Sb(n) ? n : "neutral"; }; // Mb + const ii = (e, dur) => { // ii:相位窗 {u1,u2} + const t = Math.min(.28, (e ? G.ob : G.rb) / Math.max(dur, .05)); + return { u1: (1 - t) * .42, u2: (1 - t) * .42 + t }; + }; + const ib = e => { // ib:睁开过冲曲线(si=1.06) + const t = le(e, 0, 1); + if (t < .7) { const n = t / .7; return (1 - (1 - n) * (1 - n)) * G.si; } + return G.si + (1 - G.si) * Jo((t - .7) / .3); + }; + const ab = (e, { u1: t, u2: n }) => e < t ? Jo(e / t) : e <= n ? 1 : 1 - Jo((e - n) / Math.max(1e-6, 1 - n)); // ab + const il = r => Qa(le(r.t / r.dur, 0, 1), G.tbEase); // il:morph 进度曲线 tb + let e = "neutral", t = nt(bn.neutral, wt), n = 0, r = null; + const o = () => { // eyes() + if (r == null) return t; + if (r.kind === "blink") return r.from; + if (r.kind === "morph") { + const m = il(r), { from: y, to: w, path: S } = r; + return S === "oval" ? nt(y, (b, v) => Rb(b, w[v], m, G)) : S === "spine" ? nt(y, (b, v) => _b(b, w[v], m, G)) : nt(y, (b, v) => $b(b, w[v], m, G)); + } + const f = Math.min(r.t / r.dur, 1), { u1: h, u2: p } = ii(!1, r.dur); + if (f < h) return nt(r.from, m => ol(m, Jo(f / h), G)); + if (f <= p) return nt(r.to, Fb); + const g = ib((f - p) / Math.max(1e-6, 1 - p)); + return nt(r.to, m => ol(m, 1 - g, G)); + }; + const s = () => { // inspectWeight() + if (r == null) return n; + if (r.kind === "blink") return r.fromInspect; + const f = r.fromInspect, h = sl(r.toName); + if (r.kind === "morph") return f + (h - f) * il(r); + const p = Math.min(r.t / r.dur, 1), { u1: g, u2: m } = ii(!1, r.dur); + return p <= g ? f : p < m ? 0 : h; + }; + const i = f => { // morph + const h = nt(o(), wt), p = bn[f], g = h.some(ts) || p.some(ts) || e === "sleepy" || f === "sleepy"; + r = { kind: "morph", path: !(h.some(ts) || p.some(ts)) && (h.some(jt) || p.some(jt)) ? "oval" : g ? "spine" : "stadium", fromInspect: s(), toName: f, from: h, to: p, t: 0, dur: G.nb, isHeld: !1 }; + e = f; + }; + const a = f => { // lidMorph + r = { kind: "lidMorph", fromInspect: s(), toName: f, from: nt(o(), wt), to: bn[f], t: 0, dur: G.ta, isHeld: !1 }; + e = f; + }; + const c = () => { // blink + r != null && !r.isHeld || (r = { kind: "blink", fromInspect: n, toName: e, from: nt(t, wt), to: bn[e], t: 0, dur: G.na, isHeld: !1 }); + }; + const u = f => { // play(expression event) + f.name === e && (r == null || r.toName === f.name) || (f.isThroughBlink && !Lr(f.name) && !Lr(e) ? a(f.name) : i(f.name)); + }; + const l = (f, h) => { // settle + h >= f.dur ? (t = nt(f.to, wt), n = sl(f.toName), r = null) : r = h < 0 ? null : { ...f, t: h }; + }; + const d = f => { r != null && (r = { ...r, isHeld: f }); }; + return { + get name() { return e; }, + get isMorphing() { return r != null && r.kind !== "blink" && !r.isHeld; }, + eyes: o, inspectWeight: s, + blinkAmount() { + if (r?.kind !== "blink" || r.isHeld) return null; + const f = Math.min(r.t / r.dur, 1); + return le(ab(f, ii(!0, r.dur)), 0, 1); + }, + morph: i, lidMorph: a, blink: c, play: u, + step(f) { r != null && !r.isHeld && l(r, r.t + f); }, + release() { d(!1); }, + seek(f, h) { + e = "neutral", t = nt(bn.neutral, wt), n = 0, r = null; + let p = 0; + const g = m => { r != null && l(r, m - p); }; + for (const m of f) { + if (m.t > h + 1e-9) break; + g(m.t), u(m), p = m.t; + } + g(h), d(!0); + }, + Mb, + }; +} + +// ============================================================ +// 环境 morph 行为器 cx:amount/swap/spin 弹簧 + shot 重触发 +// ============================================================ +function createMorphBehavior(G, e = {}) { // cx + const t = e.isReducedMotion ?? !1, n = tc(e.seed ?? 2654435769), + r = To(0), o = To(1), s = To(0); + let i = null, a = null, c = null, u = null, l = !1, d = 0, f = 0, h = 1, p = !1, g = 0, m = 0, y = 0, w = 0, S = 0; + const sx = G.morphShotSet, ix = G.morphShotSeconds, ax = 1.5, rx = .1, nx = Math.PI; + const nc = e2 => ix[e2] ?? 2.5; + const b = () => { + const v = i == null ? null : G.stateMorphMap[i], T = y; + i !== a && (a = i, i != null && (y = g, l = !1)); + let A = v != null; + i != null && sx.has(i) && (!l && g - y > nc(i) ? (l = !0, d = g) : l && g - d > ax && (l = !1, y = g), A = !l), + r.t = A ? 1 : 0, + v != null && v !== c && (c != null && r.x > .02 ? (u = c, w = m, S = T, o.x = t ? 1 : 0) : (u = null, o.x = 1), o.v = 0, o.t = 1, c = v, m = g), + v == null && r.x < .004 && (c = null, u = null), + o.x > .996 && (u = null), + A !== p && (A && !t && (h = n() < .5 ? 1 : -1), t || (f += nx * h, s.t = f), p = A); + }; + return { + get target() { return i; }, + set(v) { i = v; }, + advance(v) { + const T = v > 0 ? Math.min(v, rx) : 0; + g += T, b(); + const A = Bf(T), F = T / A; + for (let N = 0; N < A; N++) Eo(r, 14, 1, F), Eo(o, 11, 1, F), Eo(s, 14, 1, F); + t && (o.x = 1, s.x = s.t, r.x = r.t); + }, + settle() { b(); for (const v of [r, o, s]) v.x = v.t, v.v = 0; b(); }, + snapshot() { + return { + amount: le(r.x, 0, 1), spin: s.x, kind: c, outgoing: u, swap: le(o.x, 0, 1), + clock: { stateTime: g - m, shotTime: g - y }, outgoingClock: { stateTime: g - w, shotTime: g - S }, + isReducedMotion: t, + isSettled: Math.abs(r.x - r.t) < .001 && Math.abs(s.t - s.x) < .01 && o.x > .996, + }; + }, + }; +} + +// ============================================================ +// clip 规范化 kx / 求值 vt / rest 帧 ul / 相似判定 wx / bridge bx +// ============================================================ +const mx = ({ t: e, v: t, ease: n }, nr) => ({ t: e, v: typeof t == "string" ? [] : t, ease: n?.length === 4 ? n : nr }); // mx +const gx = (e, G) => [{ t: e, v: [0], ease: G.nr }, { t: e + G.na * .45, v: [1], ease: G.nr }, { t: e + G.na, v: [0], ease: G.nr }]; // gx:blink 事件→lids 关键帧 +function kx(e, G) { // kx + const t = new Map; + for (const [i, a] of Object.entries(e.tracks)) { + const c = G.trackAliases.get(i); + c !== void 0 && t.set(c, a); + } + const n = i => (t.get(i) ?? []).map(kf => mx(kf, G.nr)), r = n("lids"), o = []; + let s = !1; + for (const i of t.get("expression") ?? []) { + if (i.v === "blink") { s = !0, r.push(...gx(i.t, G)); continue; } + o.push({ t: i.t, name: typeof i.v == "string" ? G.Mb(i.v) : "neutral", isThroughBlink: i.cut === !1 }); + } + return s && r.sort((i, a) => i.t - a.t), { + duration: e.duration, body: G.bodyAlias(e.body ?? "sphere"), squashAnchor: e.squashAnchor ?? 0, + restTime: e.workArea ? e.workArea.start : 0, + inspect: { bias: Number.isFinite(e.inspect?.bias) ? le(e.inspect.bias, -1, 1) : void 0, isFollowingTurn: e.inspect?.follow }, + position: n("position"), rotation: n("rotation"), squash: n("squash"), zoom: n("zoom"), lids: r, expression: o, + }; +} +function vt(e, t) { // vt:track 求值 + if (e.length === 0) return null; + if (t <= e[0].t) return e[0].v; + const n = e[e.length - 1]; + if (t >= n.t) return n.v; + let r = 0; + for (; r < e.length - 2 && e[r + 1].t <= t;) r++; + const o = e[r], s = e[r + 1], i = (t - o.t) / Math.max(s.t - o.t, 1e-9), a = Qa(i, o.ease); + return o.v.map((c, u) => c + (s.v[u] - c) * a); +} +function dl(e, t) { let n = "neutral"; for (const r of e) { if (r.t > t + 1e-9) break; n = r.name; } return n; } // dl +function ul(e, G) { // ul:rest 帧 + const t = e.restTime, n = vt(e.rotation, t), r = vt(e.position, t), o = vt(e.squash, t), s = vt(e.zoom, t), i = vt(e.lids, t); + let a = "neutral", c = !1; + for (const u of e.expression) c || (a = u.name, c = !0), u.t <= t + 1e-9 && (a = u.name); + return { + rot: n ? [n[0], n[1], n[2]] : [0, 0, 0], pos: r ? [r[0], r[1]] : [0, 0], + scl: o ? [o[0], o[1]] : [1, 1], zoom: s ? s[0] : G.$n, + lids: i ? le(i[0], 0, 1) : 0, expr: a, squashAnchor: e.squashAnchor, + }; +} +const ai = (e, t) => e + 360 * Math.round((t - e) / 360); // ai +const ci = (e, t) => Math.abs(((e - t) % 360 + 540) % 360 - 180); // ci +function wx(e, t) { // wx:rest 帧相似判定 + return ci(e.rot[0], t.rot[0]) < 4 && ci(e.rot[1], t.rot[1]) < 4 && ci(e.rot[2], t.rot[2]) < 4 + && Math.abs(e.pos[0] - t.pos[0]) < .015 && Math.abs(e.pos[1] - t.pos[1]) < .015 + && Math.abs(e.scl[0] - t.scl[0]) < .02 && Math.abs(e.scl[1] - t.scl[1]) < .02 + && Math.abs(e.zoom - t.zoom) < .02 && Math.abs(e.lids - t.lids) < .05; +} +function bx(e, t, G) { // bx:bridge clip(.35s,ease dx→nr) + const n = (o, s) => [{ t: 0, v: o, ease: G.dxEase }, { t: G.ta, v: s, ease: G.nr }], + r = [ai(t.rot[0], e.rot[0]), ai(t.rot[1], e.rot[1]), ai(t.rot[2], e.rot[2])]; + return { + duration: G.ta, body: null, squashAnchor: e.squashAnchor, restTime: 0, inspect: {}, + position: n(e.pos, t.pos), rotation: n(e.rot, r), squash: n(e.scl, t.scl), + zoom: n([e.zoom], [t.zoom]), lids: e.lids === 0 && t.lids === 0 ? [] : n([e.lids], [t.lids]), expression: [], + }; +} + +// ============================================================ +// 注视 al/ex + look xx + poke Xb/Uf +// ============================================================ +function al(e, t, n, G) { // al:注视向量→头部旋转+微平移 + const r = e !== "sphere", o = G.gazeShapeMul[e] ?? 1; + return { + rx: -(r ? 19 : 14) * o * n, ry: (r ? 26 : 18) * o * t, + px: (r ? .07 : .03) * t, py: (r ? .055 : .024) * n, + }; +} +const ex = (e, t, n = 2, G) => { // ex:屏幕坐标→±1 注视向量(Jb=2) + const r = Math.max(e.width, 1) * n, o = s => le(s, -1, 1); + return [o((t.x - (e.left + e.width / 2)) / r), o(-(t.y - (e.top + e.height / 2)) / r)]; +}; +const XX = { // xx:look 预设 + left: { rot: [0, -20, 0], pos: [-.1, 0] }, right: { rot: [0, 20, 0], pos: [.1, 0] }, + up: { rot: [-20, 0, 0], pos: [0, .1] }, down: { rot: [20, 0, 0], pos: [0, -.1] }, center: { rot: [0, 0, 0], pos: [0, 0] }, +}; +function Uf({ poke: e, turns: t }) { // Uf:poke 时长 + switch (e) { case "spin": return .9 + .4 * (t - 1); case "bounce": return .62; case "nod": return .5; } +} +function Xb(e) { // Xb:poke 冲量合成 {pitch,yaw,lift,squash} + let t = 0, n = 0, r = 0, o = 1, s = 1; + for (const i of e) { + const a = Math.min(1, i.t / Uf(i)); + switch (i.poke) { + case "spin": n += 180 * i.turns * (1 - Math.cos(Math.PI * a)); break; + case "nod": t += 16 * fo(a, 0, 1); break; + case "bounce": { + const c = fo(a, 0, .18) + fo(a, .82, 1), u = fo(a, .18, .82); + r += .3 * Math.sin(Math.PI * le((a - .14) / .72, 0, 1)), o *= 1 + .08 * c - .04 * u, s *= 1 - .1 * c + .06 * u; + break; + } + } + } + return { pitch: t, yaw: n, lift: r, squash: [o, s] }; +} + +// ============================================================ +// 运动运行时 Yf +// ============================================================ +function createRuntime(G, e = {}) { + const t = le(e.zoomMul || 1, .2, 3), n = createFace(G); + let r = e.body ?? "sphere", o = e.body !== void 0, s = []; + const i = To(1), a = createMorphBehavior(G, { isReducedMotion: e.isReducedMotion }); + let c = null, u = [...G.baseEulerDeg], l = [0, 0], d = [1, 1], f = 0, h = G.$n * t, + p = { bias: -1, isFollowingTurn: !0 }, g = null, + m = { isHeld: !1, isAlways: e.isGazeAlways ?? !1, x: 0, y: 0, cx: 0, cy: 0, hold: 0 }, + y = G.restClip, w = 0, S = !1, b = !0, v = !1, T = null, A = null, F = null, + N = 0, R = 0, B = null, E = null, I = !1, _ = []; + const O = () => { // 采样当前 clip 各 track + const $ = vt(y.position, w); l = $ ? [$[0], $[1]] : [0, 0]; + const q = vt(y.rotation, w); u = q ? [q[0], q[1], q[2]] : [0, 0, 0]; + const U = vt(y.squash, w); d = U ? [U[0], U[1]] : [1, 1]; + const P = vt(y.zoom, w); P && (h = le(P[0], .25, 2.5) * t); + }, H = $ => { (n.blinkAmount() ?? fe()) > 0 ? E = $ : ($(), K()); }, + L = ($, q) => { if (B == null) for (const U of y.expression) U.t <= $ || U.t > q || H(() => n.play(U)); }, + j = $ => { w = $, g = null, E = null, O(), n.seek(y.expression, $), B != null && n.seek([{ t: 0, name: B, isThroughBlink: !1 }], 0); }, + J = () => { const $ = vt(y.lids, w); return $ ? le($[0], 0, 1) : 0; }, + fe = () => I ? 0 : J(), + K = () => { !G.Tb(n.name) || n.isMorphing ? I = !0 : I && J() === 0 && (I = !1); }, + te = () => ({ rot: u, pos: l, scl: d, zoom: h / t, lids: fe(), expr: n.name, squashAnchor: f }), + Z = $ => { S = !1, y = $, w = 0, f = $.squashAnchor, $.body != null && !o && (r = $.body), p = { bias: $.inspect.bias ?? p.bias, isFollowingTurn: $.inspect.isFollowingTurn ?? p.isFollowingTurn }; }, + D = $ => { + if (A = null, Z($.clip), T = { clip: $.clip, name: $.name }, b = !0, !$.isPlaying) { j(0), $.isRest && (m = { ...m, hold: 0 }); return; } + S = !0, $.phase > 0 && j($.phase * $.clip.duration), n.release(), $.phase <= 0 && L(-1e-9, 0); + }, + Gx = $ => { const q = te(), U = ul($.clip, G); A = $, Z(bx(q, U, G)), b = !1, S = !0, B == null && q.expr !== U.expr && H(() => n.morph(U.expr)); }, + Y = () => A != null || T == null || wx(te(), ul(T.clip, G)) ? !1 : (Gx({ ...T, isPlaying: !0, phase: 0, isRest: !1 }), !0), + Q = $ => { + const q = w; let U = q + $; + if (U >= y.duration) { + if (L(q, y.duration), b) { + if (R += 1, Y()) return; + U = U - y.duration, U >= y.duration && (U = 0), L(-1e-9, U), w = U; + } else w = y.duration, S = !1, A != null && D(A); + } else L(q, U), w = U; + g = null, O(); + }, + ne = ($, q) => { const U = $ + (q - $) * G.Yb; return Math.abs(q - U) < G.Ax ? q : U; }, + se = () => { + if (v || !m.isHeld && m.hold === 0 || (m = { ...m, cx: ne(m.cx, m.x), cy: ne(m.cy, m.y), hold: ne(m.hold, m.isHeld ? 1 : 0) }, S)) return; + O(); + const $ = al(r, m.cx, m.cy, G), { hold: q } = m; + u = [u[0] + ($.rx - u[0]) * q, u[1] + ($.ry - u[1]) * q, u[2] * (1 - q)], + l = [l[0] + ($.px - l[0]) * q, l[1] + ($.py - l[1]) * q]; + }, + ee = $ => { // look 补间 + if (g == null) return; + const q = g.t + $, U = Math.min(q / G.Sx, 1), P = Qa(U, G.vxEase), { fromRot: C2, toRot: z, fromPos: W, toPos: V } = g; + u = [C2[0] + (z[0] - C2[0]) * P, C2[1] + (z[1] - C2[1]) * P, C2[2] + (z[2] - C2[2]) * P], + l = [W[0] + (V[0] - W[0]) * P, W[1] + (V[1] - W[1]) * P], + U >= 1 ? (u = z, l = V, g = null) : g = { ...g, t: q }; + }, + me = () => { // 身体交叉淡化权重 + if (s.length === 0) return [{ body: r, weight: 1 }]; + const $ = Qo(le(i.x, 0, 1)), q = new Map; + for (const C2 of s) q.set(C2.body, (q.get(C2.body) ?? 0) + C2.weight * (1 - $)); + q.set(r, (q.get(r) ?? 0) + $); + const U = [...q].filter(([, C2]) => C2 > G.Ex), P = U.reduce((C2, [, z]) => C2 + z, 0); + return U.map(([C2, z]) => ({ body: C2, weight: z / P })); + }, + de = $ => { $ !== r && (s = me(), r = $, i.x = 0, i.v = 0, i.t = 1); }, + xe = $ => { + if (s.length === 0) return; + const q = Bf($); + for (let U = 0; U < q; U++) Eo(i, G.Tx, 1, $ / q); + Math.abs(i.x - 1) < .001 && Math.abs(i.v) < .01 && (i.x = 1, i.v = 0, s = []); + }, + Pe = () => n.blinkAmount() ?? fe(), + be = ($, q) => { // 眼睛描述符(inspect 加权 + 眨眼挤压) + const U = jb(n.eyes(), q, p, $, G), P = Pe(); + return P <= 1e-4 ? U : [rl(U[0], P, G), rl(U[1], P, G)]; + }; + return { + get isPlaying() { return S; }, + get time() { return w; }, + get duration() { return y.duration; }, + get loopCount() { return R; }, + get face() { return n.name; }, + get isSettled() { + const $ = v || m.hold === (m.isHeld ? 1 : 0) && (!m.isHeld || m.cx === m.x && m.cy === m.y); + return !S && _.length === 0 && s.length === 0 && g == null && E == null && !n.isMorphing && n.blinkAmount() == null && a.snapshot().isSettled && $; + }, + load($, { isPlaying: q = !0, transition: U = "bridge", phase: P = 0 } = {}) { + const C2 = $.name || T?.name || "recipe", + z = { clip: kx($, G), name: C2, isPlaying: q, phase: le(P, 0, 1) % 1, isRest: !1 }; + U === "bridge" && T != null && (A != null || T.name !== C2) ? Gx(z) : D(z); + }, + play() { v = !1, S = !0; }, + pause() { v = !0, S = !1; }, + stop() { S = !1, j(0); }, + rest() { + v = !1; + const $ = { clip: G.restClip, name: "rest", isPlaying: !1, phase: 0, isRest: !0 }; + if (A == null && (T == null || T.name === "rest")) { T == null && D($); return; } + Gx($); + }, + seek: j, + setBody($, { isInstant: q = !1 } = {}) { + if (o = !0, !q) { de($); return; } + r = $, s = [], i.x = 1, i.v = 0; + }, + setMorph($, { isInstant: q = !1 } = {}) { a.set($), q && a.settle(); }, + setExpression($, { isThroughBlink: q = !1 } = {}) { + H(() => { q && !G.Lr($) && !G.Lr(n.name) ? n.lidMorph($) : n.morph($); }); + }, + holdFace($) { + $ !== B && (B = $, H(() => { + const q = B ?? (A == null ? dl(y.expression, w) : dl(A.clip.expression, 0)); + q !== n.name && n.morph(q); + })); + }, + poke($, { turns: q = 1 } = {}) { _ = [..._, { poke: $, t: 0, turns: Math.max(1, Math.round(q)) }]; }, + blink() { n.blink(); }, + setInspect({ bias: $, isFollowingTurn: q }) { + p = { bias: $ === void 0 ? p.bias : le($, -1, 1), isFollowingTurn: q ?? p.isFollowingTurn }; + }, + setGaze($) { + if ($ == null) { m = { ...m, isHeld: !1 }; return; } + const q = le($[0] || 0, -1, 1), U = le($[1] || 0, -1, 1); + m = m.hold === 0 ? { ...m, isHeld: !0, x: q, y: U, cx: q, cy: U } : { ...m, isHeld: !0, x: q, y: U }; + }, + setGazeAlways($) { m = { ...m, isAlways: $ }; }, + look($) { const q = XX[$]; g = { t: 0, fromRot: u, toRot: q.rot, fromPos: l, toPos: q.pos }; }, + setGlow($) { F = $; }, + advance($) { + se(); + const q = Math.min($, G.Mx), U = 1 - a.snapshot().amount; + if (S && Q(q * U), ee(q), n.step(q), E != null && (n.blinkAmount() ?? fe()) === 0) { + const C2 = E; E = null, C2(); + } + K(), xe(q), _ = _.map(C2 => ({ ...C2, t: C2.t + q })).filter(C2 => C2.t < Uf(C2)), a.advance(q); + const P = a.snapshot(); + P.amount > 0 || !P.isSettled ? c ??= [ui(u[0]), ui(u[1]), ui(u[2])] : c = null, N += $; + }, + frame() { + const $ = m.isAlways && S && !v ? al(r, m.cx, m.cy, G) : null, q = G.Zb * m.hold, U = Xb(_), + P = [u[0] + ($ ? $.rx * q : 0) + U.pitch, u[1] + ($ ? $.ry * q : 0) + U.yaw, u[2]], + C2 = [l[0] + ($ ? $.px * q : 0), l[1] + ($ ? $.py * q : 0) + U.lift], + z = [d[0] * U.squash[0], d[1] * U.squash[1]], + W = n.inspectWeight(), V = a.snapshot(), X = 1 - V.amount, + ie = V.spin % TAU / DEG, he = c ?? [0, 0, 0], pe = ue => he[ue] + (P[ue] - he[ue]) * X, ge = pe(1); + return { + body: r, quat: lb(pe(0) * DEG, (ge + ie) * DEG, pe(2) * DEG), zoom: h, + offset: X === 1 ? C2 : [C2[0] * X, C2[1] * X], + squash: X === 1 ? z : [1 + (z[0] - 1) * X, 1 + (z[1] - 1) * X], + squashAnchor: f, eyes: V.amount >= G.Px ? null : be(ge, W), + glow: F && { ...F, midT: .5 + .5 * Math.sin(N * 1e3 / 860) }, + inspectWeight: W, bodies: me(), morph: V.kind === null && V.isSettled ? null : V, + }; + }, + }; +} +const ui = e => 360 * Math.round(e / 360); // ui + +// ============================================================ +// 状态机 Nx +// ============================================================ +function createStateMachine(G, e, t, n = {}) { // Nx(runtime, initialState, opts) + const r = tc(n.seed ?? 1), o = new Map; + let s = n.isCompact ?? !1, i = n.isResting ?? !1, a = !1, c = i, u = t, l = G.states[t], d = 0, f = 0, h = e.loopCount, p = null; + const g = () => s && l.compactClips != null ? l.compactClips : l.clips, + m = () => l.next != null && l.holdSeconds == null, + y = () => { + const v = g(), T = o.get(v) ?? []; + return T.length === 0 && T.push(...Lx(v, r, p)), o.set(v, T), T.shift(); + }, + w = () => { + const v = m() ? g()[f] : y(); + if (h = e.loopCount, v === void 0 || v === p) return; + const T = c || m(), clip = G.clipByName(v); + e.load(clip, { phase: T ? 0 : n.firstPhase ?? r() }), c = !0, p = v, h = e.loopCount; + }, + S = () => { e.holdFace(l.face ?? (a ? "surprised" : null)); }, + b = v => { u = v, l = G.states[v], d = 0, f = 0, e.setMorph(l.morph ?? null), S(), i || w(); }; + return i && e.rest(), b(t), { + get state() { return u; }, + get clip() { return p; }, + setState(v) { v !== u && G.states[v] && b(v); }, + setCompact(v) { s = v; }, + setEmphasis(v) { v !== a && (a = v, S()); }, + setResting(v) { + if (v !== i) { + if (i = v, !i) { w(); return; } + e.rest(), p = null; + } + }, + advance(v) { + if (i) return; + d += v; + const T = l.next; + if (T != null && l.holdSeconds != null && d >= l.holdSeconds) { b(T); return; } + if (e.loopCount !== h) { + if (T != null && l.holdSeconds == null && (f += 1, f >= g().length)) { b(T); return; } + w(); + } + }, + }; +} + +// jb:inspect 加权(眼睛大小随 yaw 偏向) +function jb(e, t, n, r, G) { + if (t < .001) return e; + const o = n.isFollowingTurn ? le(r / G.Ib, -1, 1) : n.bias, + [s, i] = G.expressions.inspecting, a = Math.max(1e-4, s.wid), c = Math.max(1e-4, i.wid), u = (1 + o) / 2, + l = (d, f) => { + const h = 1 + (f - 1) * t; + return { ...wt(d), wid: d.wid * h, pts: d.pts.map(p => [p[0] * h, p[1] * h]) }; + }; + return [l(e[0], Math.pow(c / a, u)), l(e[1], Math.pow(a / c, u))]; +} + +// ============================================================ +// 眼睛 SDF 轮廓追踪(sT/aT/iT/cT + UM marching squares) +// ============================================================ +const BM = 60, HM = 256, VM = 24, GM = 2e4, WM = .2; +const JM = .02, eT = 16, tT = .015, nT = 400, rT = 512, ls = 5; +const kn = new Map; // oT 缓存(LRU) +const uT = (e, t, n) => Jo(le((n - e) / (t - e), 0, 1)); // uT +function sT(e) { // sT:眼睛描述符→SDF 场描述 + const [t, n] = e.oval, + o = t > 1e-5 && n > 1e-5 ? e.ovalMix > 1e-4 ? Math.min(1, e.ovalMix) : uT(0, .08, Math.min(t, n)) : 0, + s = Math.max(0, e.pts.length - 1), i = new Float64Array(s * ls); + for (let a = 0; a < s; a++) { + const [c, u] = e.pts[a], l = e.pts[a + 1][0] - c, d = e.pts[a + 1][1] - u, f = l * l + d * d; + i.set([c, u, l, d, f > 0 ? 1 / f : 0], a * ls); + } + return { segments: i, halfWidth: e.wid > 0 ? e.wid : -1, rx: t, ry: n, ellipseMix: o }; +} +function aT(e, t, n) { // aT:脊线折线距离 + let r = 1e6; + for (let o = 0; o < e.length; o += ls) { + const s = t - e[o], i = n - e[o + 1], a = e[o + 2], c = e[o + 3], + u = (s * a + i * c) * e[o + 4], l = u < 0 ? 0 : u > 1 ? 1 : u, d = s - a * l, f = i - c * l, h = d * d + f * f; + h < r && (r = h); + } + return Math.sqrt(r); +} +function iT(e, t, n) { // iT:混合 SDF(stadium↔ellipse) + const r = aT(e.segments, t, n) - e.halfWidth; + if (e.ellipseMix === 0) return r; + const o = t / e.rx, s = n / e.ry, i = (Math.sqrt(o * o + s * s) - 1) * Math.min(e.rx, e.ry); + return r * (1 - e.ellipseMix) + i * e.ellipseMix; +} +function cT(e, t) { // cT:等值线追踪入口 + let n = 1 / 0, r = 1 / 0, o = -1 / 0, s = -1 / 0, i = 1 / 0; + const { segments: a, halfWidth: c } = e; + if (c > 0 && e.ellipseMix < 1 && a.length > 0) { + for (let d = 0; d < a.length; d += ls) + for (const [f, h] of [[a[d], a[d + 1]], [a[d] + a[d + 2], a[d + 1] + a[d + 3]]]) + n = Math.min(n, f - c), r = Math.min(r, h - c), o = Math.max(o, f + c), s = Math.max(s, h + c); + i = c; + } + if (e.ellipseMix > 0 && (n = Math.min(n, -e.rx), r = Math.min(r, -e.ry), o = Math.max(o, e.rx), s = Math.max(s, e.ry), i = Math.min(i, e.rx, e.ry)), !(o > n && s > r)) return []; + const u = Math.max(Math.min(tT, i / 3), Math.max(o - n, s - r) / nT), + l = { x0: n - 2 * u, y0: r - 2 * u, step: u, nx: Math.ceil((o - n) / u) + 4, ny: Math.ceil((s - r) / u) + 4 }; + return UM((d, f) => iT(e, d, f), l, { deviation: t, edge: Math.sqrt(4 * t) }); +} +function oT(e, t) { // oT:带 LRU 缓存的轮廓提取 + const n = Math.min(eT, Math.max(0, Math.ceil(Math.log2(Math.max(t, 1))))), + r = `${n}|${e.wid}|${e.oval.join()}|${e.ovalMix}|${e.pts.join()}`, + o = kn.get(r); + if (o !== void 0) return kn.delete(r), kn.set(r, o), o; + const s = cT(sT(e), JM / 2 ** n); + if (kn.set(r, s), kn.size > rT) { const i = kn.keys().next(); i.done !== !0 && kn.delete(i.value); } + return s; +} +// UM:marching squares + 自适应细分 + 多边形简化 +function UM(e, t, n) { + const r = { field: e, tolerance: n, root: n.deviation * .001, reach: t.step * Math.hypot(t.nx, t.ny) }, o = []; + for (const s of KM(r, t)) { const i = ZM(XM(r, s), n); i.length >= 3 && o.push(i); } + return o; +} +function KM(e, t) { // KM:网格采样 + 边交叉图 + 环追踪 + const { field: n } = e, { x0: r, y0: o, step: s, nx: i, ny: a } = t, c = i + 1, u = new Float64Array(c * (a + 1)); + for (let b = 0; b <= a; b++) for (let v = 0; v <= i; v++) u[b * c + v] = n(r + v * s, o + b * s); + const l = 2 * c * (a + 1), d = new Int32Array(l).fill(-1), f = new Map, + h = b => { + const v = f.get(b); + if (v !== void 0) return v; + const T = b >> 1, A = b & 1 ? T + c : T + 1, F = r + T % c * s, N = o + Math.floor(T / c) * s, + R = r + A % c * s, B = o + Math.floor(A / c) * s, E = Ah(e, F, N, u[T], R, B, u[A]), + I = [F + (R - F) * E, N + (B - N) * E]; + return f.set(b, I), I; + }, + p = [0, 0, 0, 0], g = [0, 0, 0, 0], m = [], y = []; + for (let b = 0; b < a; b++) for (let v = 0; v < i; v++) { + const T = b * c + v; + p[0] = T, p[1] = T + 1, p[2] = T + 1 + c, p[3] = T + c, g[0] = 2 * T, g[1] = 2 * (T + 1) + 1, g[2] = 2 * (T + c), g[3] = 2 * T + 1, m.length = 0, y.length = 0; + for (let A = 0; A < 4; A++) { + const F = u[p[A]] < 0, N = u[p[A + 1 & 3]] < 0; + F !== N && (m.push(g[A]), y.push(F)); + } + if (m.length === 2) { const A = y[0] ? 0 : 1; d[m[A]] = m[1 - A]; } + else if (m.length === 4) { + const A = n(r + (v + .5) * s, o + (b + .5) * s) < 0; + for (let F = 0; F < 4; F++) y[F] && (d[m[F]] = m[F + (A ? 1 : 3) & 3]); + } + } + const w = [], S = new Uint8Array(l); + for (let b = 0; b < l; b++) { + if (d[b] < 0 || S[b]) continue; + const v = []; + let T = b; + for (; T >= 0 && !S[T];) S[T] = 1, v.push(h(T)), T = d[T]; + T === b && w.push(v); + } + return w; +} +function Ah(e, t, n, r, o, s, i) { // Ah:Brent-Dekker 求根 + let a = 0, c = r, u = 1, l = i, d = 0, f = 0; + for (let h = 0; h < BM; h++) { + f = (a * l - u * c) / (l - c); + const p = e.field(t + (o - t) * f, n + (s - n) * f); + if (Math.abs(p) <= e.root || u - a <= 1e-15) break; + p < 0 == l < 0 ? (u = f, l = p, d === -1 && (c /= 2), d = -1) : (a = f, c = p, d === 1 && (l /= 2), d = 1); + } + return f; +} +function XM(e, t) { // XM:细分到容差 + const n = []; + for (let r = 0; r < t.length; r++) { + const o = t[r], s = t[(r + 1) % t.length]; + o[0] === s[0] && o[1] === s[1] || (n.push(o), fa(e, o, s, 0, n)); + } + return n; +} +function fa(e, t, n, r, o) { // fa:自适应中点细分(带曲率守卫 YM) + const s = n[0] - t[0], i = n[1] - t[1], a = Math.sqrt(s * s + i * i); + if (a === 0 || r >= VM || o.length >= GM) return; + const c = (t[0] + n[0]) / 2, u = (t[1] + n[1]) / 2, l = e.field(c, u); + if (!Number.isFinite(l)) return; + const d = l > 0 ? 1 : -1, f = -i / a * d, h = s / a * d, p = Ih(e, c, u, l, f, h); + if (p === null || p <= e.tolerance.deviation / 2 && a <= e.tolerance.edge) return; + const g = l > 0 ? YM(e, c, u, l, f, h, p) : [c + f * p, u + h * p]; + fa(e, t, g, r + 1, o), o.push(g), fa(e, g, n, r + 1, o); +} +function YM(e, t, n, r, o, s, i) { // YM:曲率大时沿梯度修正中点 + const a = [t + o * i, n + s * i], c = i * .001, + u = e.field(t + c, n) - e.field(t - c, n), l = e.field(t, n + c) - e.field(t, n - c), + d = Math.sqrt(u * u + l * l); + if (d === 0) return a; + const f = u * s - l * o < 0 ? -1 : 1, h = -l / d * f, p = u / d * f; + if (h * o + p * s < WM) return a; + const g = Ih(e, t, n, r, h, p); + return g === null || g * (h * o + p * s) <= i ? a : [t + h * g, n + p * g]; +} +function Ih(e, t, n, r, o, s) { // Ih:沿法线行进求交 + if (r === 0) return 0; + let i = 0, a = r; + for (let c = 0; c < HM; c++) { + const u = i + Math.max(Math.abs(a), e.root); + if (u > e.reach) return null; + const l = t + o * u, d = n + s * u, f = e.field(l, d); + if (f < 0 != r < 0) return i + (u - i) * Ah(e, t + o * i, n + s * i, a, l, d, f); + i = u, a = f; + } + return null; +} +function ZM(e, t) { // ZM:Douglas-Peucker 式简化 + const n = e.length; + if (n < 4) return e; + const r = new Uint8Array(n); + let o = 0, s = -1; + for (let a = 1; a < n; a++) { const c = ud(e[0], e[a]); c > s && (s = c, o = a); } + r[0] = 1, r[o] = 1; + const i = (a, c) => { + if (c - a < 2) return; + const u = e[a], l = e[c % n]; + let d = -1, f = a; + for (let p = a + 1; p < c; p++) { const g = QM(e[p], u, l); g > d && (d = g, f = p); } + const h = d > t.deviation / 2; + !h && Math.sqrt(ud(u, l)) <= t.edge || (h || (f = a + c >> 1), r[f] = 1, i(a, f), i(f, c)); + }; + return i(0, o), i(o, n), e.filter((a, c) => r[c] === 1); +} + +// ============================================================ +// 球面映射 ST/RM + squash 裁剪 $M + cutout OM + 路径 ha/pa +// ============================================================ +function ST(e, t, n, r, o) { // ST:眼睛描述符→球面 path 环 + const s = oT(e, o * Math.max(1, e.lidY)); + if (s.length === 0) return []; + const i = Et(r, t.M), a = Et(r, t.C), c = Et(r, t.T), + u = Math.atan2(i[1], i[0]), l = Math.cos(e.tilt), d = Math.sin(e.tilt), [f, h] = e.off, + p = ([m, y]) => { // 罗德里格斯:2D 脊线坐标 → 单位球面 3D + const w = y * e.lidY, S = f + l * m + d * w, b = h - d * m + l * w, v = Math.sqrt(S * S + b * b), + T = v > 1e-12 ? Math.sin(v) / v : 1, A = Math.cos(v); + return [A * i[0] + T * (S * a[0] + b * c[0]), A * i[1] + T * (S * a[1] + b * c[1]), A * i[2] + T * (S * a[2] + b * c[2])]; + }, + g = []; + for (const m of s) { const y = m.map(p); n && y.reverse(); const w = RM(y, u); w !== null && g.push(w); } + return g; +} +function sd(e, t) { // sd:z=0 交点(归一到单位圆) + const n = e[2] / (e[2] - t[2]), r = e[0] + (t[0] - e[0]) * n, o = e[1] + (t[1] - e[1]) * n, s = Math.sqrt(r * r + o * o); + return [r / s, o / s]; +} +const CM = [1, 1]; +function id(e, t, n) { // id:地平线弧 + const r = cd(Math.atan2(t[1], t[0]) - n) - cd(Math.atan2(e[1], e[0]) - n); + return { kind: "arc", to: t, radii: CM, sweep: r }; +} +function RM(e, t) { // RM:前半球 z>0 裁剪 + const n = e.length; + let r = -1; + for (let a = 0; a < n && r < 0; a++) e[a][2] <= 0 && e[(a + 1) % n][2] > 0 && (r = a); + if (r < 0) return n === 0 || e[0][2] <= 0 ? null : e.map(([a, c]) => ({ kind: "line", to: [a, c] })); + const o = []; + let s = [1, 0], i = null; + for (let a = 0; a < n; a++) { + const c = e[(r + a) % n], u = e[(r + a + 1) % n], l = c[2] > 0, d = u[2] > 0; + if (l && d) o.push({ kind: "line", to: [u[0], u[1]] }); + else if (l) i = sd(c, u), o.push({ kind: "line", to: i }); + else if (d) { + const f = sd(c, u); + i === null ? s = f : o.push(id(i, f, t)), o.push({ kind: "line", to: [u[0], u[1]] }); + } + } + return i !== null && o.push(id(i, s, t)), o; +} +function xT(e, kT) { // xT:弧线按 kT 步长细分为点 + const t = []; + let n = e[e.length - 1]?.to ?? [0, 0]; + for (const r of e) { + if (r.kind === "arc") { + const o = Math.atan2(n[1], n[0]), s = Math.max(1, Math.ceil(Math.abs(r.sweep) / kT)); + for (let i = 1; i < s; i++) t.push(Ph(r.radii, o + r.sweep * i / s)); + } + t.push(r.to), n = r.to; + } + return t; +} +function Ph(e, t) { // Ph:椭圆上一点 + const n = Math.cos(t), r = Math.sin(t), o = 1 / Math.sqrt(n * n / (e[0] * e[0]) + r * r / (e[1] * e[1])); + return [o * n, o * r]; +} +function zr(e, t) { // zr:椭圆 path(两段半弧) + return [{ kind: "arc", to: [-e[0], 0], radii: e, sweep: t * Math.PI }, { kind: "arc", to: [e[0], 0], radii: e, sweep: t * Math.PI }]; +} +// —— squash<1 时把眼睛环裁剪到身体椭圆($M/LM/NM/FM) —— +function NM(e, t, n, r) { // NM:线段×椭圆交点参数 + const o = t[0] - e[0], s = t[1] - e[1], i = n * o * o + r * s * s, a = n * e[0] * o + r * e[1] * s, + c = n * e[0] * e[0] + r * e[1] * e[1] - 1, u = a * a - i * c; + if (i === 0 || u <= 0) return []; + const l = Math.sqrt(u), d = -(a + (a < 0 ? -l : l)); + return [d / i, c / d].filter(f => f > 0 && f < 1).sort((f, h) => f - h); +} +function FM(e, t, n, r, o) { // FM:圆弧×椭圆交点参数 + if (r === o || n === 0) return []; + const s = (1 / (e * e) - o) / (r - o); + if (!(s > 0 && s < 1)) return []; + const i = Math.acos(Math.sqrt(s)), a = []; + for (const c of [i, -i, Math.PI - i, Math.PI + i]) { + const l = (n > 0 ? Dr(c - t) : Dr(t - c)) / Math.abs(n); + l > 0 && l < 1 && a.push(l); + } + return a.sort((c, u) => c - u); +} +function LM(e, t) { // LM:按椭圆内外拆分 path 段 + const n = 1 / (t[0] * t[0]), r = 1 / (t[1] * t[1]), o = a => n * a[0] * a[0] + r * a[1] * a[1] - 1, s = []; + let i = e[e.length - 1].to; + for (const a of e) { + if (a.kind === "line") { + const c = NM(i, a.to, n, r); + let u = i; + for (let l = 0; l <= c.length; l++) { + const d = l === 0 ? 0 : c[l - 1], f = l === c.length ? 1 : c[l], h = l === c.length ? a.to : ad(i, a.to, f); + s.push({ from: u, segment: { kind: "line", to: h }, sweep: Math.atan2(u[0] * h[1] - u[1] * h[0], u[0] * h[0] + u[1] * h[1]), isInside: o(ad(i, a.to, (d + f) / 2)) < 0 }), u = h; + } + } else { + const c = a.radii[0], u = Math.atan2(i[1], i[0]), l = FM(c, u, a.sweep, n, r); + let d = i; + for (let f = 0; f <= l.length; f++) { + const h = f === 0 ? 0 : l[f - 1], p = f === l.length ? 1 : l[f], g = u + a.sweep * p, + m = f === l.length ? a.to : [c * Math.cos(g), c * Math.sin(g)], y = u + a.sweep * (h + p) / 2, w = a.sweep * (p - h); + s.push({ from: d, segment: { kind: "arc", to: m, radii: a.radii, sweep: w }, sweep: w, isInside: o([c * Math.cos(y), c * Math.sin(y)]) < 0 }), d = m; + } + } + i = a.to; + } + return s; +} +function $M(e, t) { // $M:path 环 → 椭圆内部分(外部以椭圆弧连接) + const n = LM(e, t), r = n.length; + if (n.every(c => c.isInside)) return e; + let o = -1; + for (let c = 0; c < r && o < 0; c++) n[c].isInside && !n[(c + r - 1) % r].isInside && (o = c); + if (o < 0) { const c = n.reduce((u, l) => u + l.sweep, 0); return Math.abs(c) > Math.PI ? zr(t, Math.sign(c)) : null; } + const s = []; + let i = null, a = 0; + for (let c = 0; c < r; c++) { + const u = n[(o + c) % r]; + u.isInside ? (i !== null && s.push({ kind: "arc", to: u.from, radii: t, sweep: a }), i = null, s.push(u.segment)) + : (i === null && (i = u.from, a = 0), a += u.sweep); + } + return i !== null && s.push({ kind: "arc", to: n[o].from, radii: t, sweep: a }), s; +} +// —— cutout:把眼睛地平线弧替换进身体轮廓(OM/zM/DM/jM/qM) —— +function zM(e, t) { // zM:反转子环 + const n = [e, ...t.map(o => o.to)], + r = t.map((o, s) => o.kind === "line" ? { kind: "line", to: n[s] } : { ...o, to: n[s], sweep: -o.sweep }).reverse(); + return { from: n[n.length - 1], to: e, segments: r }; +} +function DM(e, t, n, k) { // DM:身体椭圆上的补弧(方向必须与眼链对咬:h.to → h.from) + const r = n.map(s => ({ point: s, angle: Dr(Math.atan2(s[1], s[0])) })).sort((s, i) => s.angle - i.angle), o = []; + for (let s = 0; s < r.length; s++) { + const i = r[s], a = r[(s + 1) % r.length], c = s + 1 < r.length ? a.angle - i.angle : a.angle + TAU - i.angle; + if (c <= 0) continue; + const u = i.angle + c / 2; + if (t.reduce((l, d) => l + jM(d, u), 0) !== 0) continue; + // 升序起点是眼弧 from(负 sweep 眼弧会这样)时反向发射,否则 qM 接不成环 → 闪白 + k && !k.has(`${i.point[0]} ${i.point[1]}`) && k.has(`${a.point[0]} ${a.point[1]}`) + ? o.push({ from: a.point, to: i.point, segments: [{ kind: "arc", to: i.point, radii: e, sweep: -c }] }) + : o.push({ from: i.point, to: a.point, segments: [{ kind: "arc", to: a.point, radii: e, sweep: c }] }); + } + return o; +} +function jM(e, t) { // jM:角是否在弧内 + const n = Math.atan2(e.from[1], e.from[0]); + return (e.sweep > 0 ? Dr(t - n) : Dr(n - t)) < Math.abs(e.sweep) ? Math.sign(e.sweep) : 0; +} +function qM(e) { // qM:链式拼接子环 + const t = s => `${s[0]} ${s[1]}`, n = new Map; + for (const s of e) { const i = n.get(t(s.from)); i === void 0 ? n.set(t(s.from), [s]) : i.push(s); } + const r = new Set, o = []; + for (const s of e) { + const i = []; + let a = s; + for (; a !== void 0 && !r.has(a);) r.add(a), i.push(...a.segments), a = n.get(t(a.to))?.find(c => !r.has(c)); + i.length > 0 && o.push(i); + } + return o; +} +function OM(e, t) { // OM:身体轮廓抠掉眼睛 + const n = a => a.kind === "arc" && a.radii[0] === e[0] && a.radii[1] === e[1], r = [], o = [], s = [], i = [], k = new Set; + for (const a of t) { + const c = a.findIndex(n); + if (c < 0) { r.push(a); continue; } + let u = a[c].to, l = [], d = u; + for (let f = 1; f <= a.length; f++) { + const h = a[(c + f) % a.length]; + n(h) ? (s.push({ from: d, sweep: h.sweep }), i.push(d, h.to), k.add(`${h.to[0]} ${h.to[1]}`), l.length > 0 && o.push(zM(u, l)), l = [], u = h.to) : l.push(h), d = h.to; + } + } + return s.length === 0 ? [zr(e, 1), ...r] : [...qM([...o, ...DM(e, s, i, k)]), ...r]; +} +// —— path 序列化(ha/pa,kT=π/48 弧细分由调用方 xT 处理或 pa 按 _h 劈开) —— +const _h = Math.PI * .999; +function ha(e, t, n) { // ha:path 环 → SVG d + let r = e[e.length - 1].to, o = `M${t(r)}`; + for (const s of e) o += s.kind === "line" ? `L${t(s.to)}` : pa(r, s, t, n), r = s.to; + return `${o}Z`; +} +function pa(e, t, n, r) { // pa:弧 → SVG A(>π 劈半) + if (t.sweep === 0) return ""; + if (Math.abs(t.sweep) > _h) { + const s = t.sweep / 2, i = Ph(t.radii, Math.atan2(e[1], e[0]) + s); + return pa(e, { ...t, to: i, sweep: s }, n, r) + pa(i, { ...t, sweep: s }, n, r); + } + return `A${`${qt(t.radii[0] * r)} ${qt(t.radii[1] * r)}`} 0 0 ${t.sweep > 0 ? 0 : 1} ${n(t.to)}`; +} +const qt = e => String(Math.round(e * 100) / 100); // qt +const od = e => String(Math.round(e * 100) / 100); // od +const Se = (e, t = 2) => { const n = 10 ** t; return String(Math.round(e * n) / n); }; // Se + +// ============================================================ +// sphere 身体(bT/wT)+ glow(TT/AT) +// ============================================================ +const yT = 192, kT = Math.PI / 48; +function Ch(e, t, G) { // Ch:frame→眼睛 path 环(单位球面) + if (e.eyes === null) return []; + const n = ir(e.quat), r = [Math.abs(e.squash[0]), Math.abs(e.squash[1])], o = r[0] < 1 || r[1] < 1, s = []; + return e.eyes.forEach((i, a) => { + for (const c of ST(i, G.eyeBasis[a], G.eyeMirror[a], n, t)) { + const u = o ? $M(c, r) : c; + u !== null && s.push(u); + } + }), s; +} +function wT(e, t, n, G) { // wT:sphere → {size, body, eyes, glow}(弧线路径) + const o = e.zoom * n / 2, [s, i] = e.squash; + if (o === 0 || s === 0 || i === 0) return { size: n, body: "", eyes: "", glow: null }; + const a = [Math.abs(s), Math.abs(i)], c = n / 2 + o * e.offset[0], u = n / 2 - o * (e.squashAnchor * (1 - i) + e.offset[1]), + l = h => `${qt(c + h[0] * o)} ${qt(u - h[1] * o)}`, + d = h => h.map(p => ha(p, l, Math.abs(o))).join(""), + f = Ch(e, Math.abs(o), G); + return { + size: n, + body: d(t.cutout ? OM(a, f) : [zr(a, 1)]), + eyes: t.cutout ? "" : d(f), + glow: e.glow === null ? null : TT(e.glow, a, l, Math.abs(o)), + center: [c, u], radius: Math.abs(o), + }; +} +const ld = 48, dd = 11, gr = .055; +const hd = (e, t, n) => [e[0] + (t[0] - e[0]) * n, e[1] + (t[1] - e[1]) * n, e[2] + (t[2] - e[2]) * n]; // hd +function Nr(e) { // Nr:rgb 数组→hex + const t = n => Math.round(le(n, 0, 1) * 255).toString(16).padStart(2, "0"); + return `#${t(e[0])}${t(e[1])}${t(e[2])}`; +} +function AT(e, t, n) { // AT:glow 渐变色(inner→mid→rim 双 smoothstep + 边缘压暗) + const r = ma(.12, t, n), o = ma(t, .93, n), s = .9 + .1 * Math.max(0, 1 - n * n) ** .225, + i = a => { const c = e.inner[a] + (e.mid[a] - e.inner[a]) * r; return (c + (e.rim[a] - c) * o) * s; }; + return [i(0), i(1), i(2)]; +} +function pd(e, t, n) { return `matrix(${qt(e[0] * n)} 0 0 ${qt(e[1] * n)} ${t([0, 0])})`; } // pd +function TT(e, t, n, r) { // TT:body 49 stops 径向渐变 + halo 环 + const o = .42 + .16 * e.midT, s = []; + for (let u = 0; u <= ld; u++) { const l = 1 - (1 - u / ld) ** 2; s.push({ offset: l, color: Nr(AT(e, o, l)), opacity: 1 }); } + const i = { transform: pd(t, n, r), focus: 0, stops: s }; + if (!(e.halo > 0)) return { body: i, halo: null }; + const a = []; + for (let u = 0; u <= dd; u++) { + const l = gr * u / dd, d = Math.min(1, e.halo * (1 - ma(0, gr, l))); + a.push({ offset: (1 + l) / (1 + gr), color: Nr(e.rim), opacity: d }); + } + const c = [t[0] * (1 + gr), t[1] * (1 + gr)]; + return { body: i, halo: { d: ha(zr(c, 1), n, r) + ha(zr(t, 1), n, r), gradient: { transform: pd(c, n, r), focus: 0, stops: a } } }; +} + +// ============================================================ +// morph 覆盖层:12 生成器 + yE 合成器 + 身体轮廓混合 +// ============================================================ +function Lh(e, t, n, r, G) { // Lh:dots 的 lift/pop/tone + const o = Bt(e.stateTime * 1e3 / G.FT + .119), s = Math.abs(o - t / 3), i = Math.min(s, 1 - s), + a = r ? 1 : Math.exp(-(i * i) / (2 * .15 * .15)), c = r ? 0 : 1; + return { lift: a * G.NT * n * c, pop: 1 + c * (G.$T + G.OT * a - 1), tone: 1 - c * .5 * (1 - a) }; +} +const ZT = e => .42 + .29 * Math.sin(e * .0021) * Math.sin(e * .0034) + .29 * Math.sin(e * .0013 + 1.7); // ZT +const QT = (e, t) => ZT(e) * (.55 + .45 * Math.sin(e * .012 - Math.abs(t) * 1.05)); // QT +const JT = e => -Math.PI * 1.25 + tc(e)() * Math.PI * 1.5; // JT +function eE(e) { // eE:ball 弹跳高度 + const r = 1082.2060353798126, o = 40, s = Math.sqrt(2 * o / r); + if (e < s) return .5 * r * e * e; + const i = Bt((e - s) / .62); + return o - 208 * i * (1 - i); +} +function pc(e, G) { // pc:pencil 书写轨迹 + const t = Bt(e / G.ds); + if (t < G.Mr) { + const r = t / G.Mr, o = r * r * (3 - 2 * r), s = le(r / .08, 0, 1) * le((1 - r) / .08, 0, 1); + return { x: -54 + 118 * o, y: 26, wig: Math.sin(r * 24) * 3.2 * s, rot: 17 + Math.sin(e * 6e-4) * 1 }; + } + const n = Qo((t - G.Mr) / (1 - G.Mr)); + return { x: 64 - 118 * n, y: 26 - 20 * Math.sin(n * Math.PI), wig: 0, rot: 17 - 2 * Math.sin(n * Math.PI) + Math.sin(e * 6e-4) * 1 }; +} +const Nh = (e, G) => { const t = pc(e, G); return [G.x + t.x, G.x + t.y + t.wig + 19]; }; // Nh +function makeTrail(G) { // tE:pencil 墨迹采样点缓存 + const e = []; + for (let t = 0; t < G.Co; t++) { + const n = Nh(t, G), r = e[e.length - 1]; + (r === void 0 || Math.hypot(n[0] - r.point[0], n[1] - r.point[1]) > G.zT) && e.push({ at: t, point: n }); + } + return e; +} +function nE(e, G, tE) { // nE:最近 64 点墨迹 + const t = Bt(e / G.ds) * G.ds, n = Math.min(t, G.Co), r = tE.filter(a => a.at <= n), + o = r.length > 0 ? r[r.length - 1].at : n, s = n > o ? [Nh(n, G)] : [], + i = [...r.map(a => a.point), ...s].slice(-64); + return t < G.Co ? i : i.slice(Math.floor((t - G.Co) / 1e3 * G.DT)); +} +function rE(e) { // rE:Catmull-Rom → 三次贝塞尔 path + const t = e.length; + let n = `M${e[0][0].toFixed(1)} ${e[0][1].toFixed(1)}`; + if (t === 2) return n + `L${e[1][0].toFixed(1)} ${e[1][1].toFixed(1)}`; + for (let r = 0; r < t - 1; r++) { + const o = e[Math.max(r - 1, 0)], s = e[r], i = e[r + 1], a = e[Math.min(r + 2, t - 1)], + c = s[0] + (i[0] - o[0]) / 6, u = s[1] + (i[1] - o[1]) / 6, l = i[0] - (a[0] - s[0]) / 6, d = i[1] - (a[1] - s[1]) / 6; + n += `C${c.toFixed(1)} ${u.toFixed(1)} ${l.toFixed(1)} ${d.toFixed(1)} ${i[0].toFixed(1)} ${i[1].toFixed(1)}`; + } + return n; +} +const Ht = (e, t, n, r, o) => ({ type: "disc", key: e, cx: t, cy: n, r, opacity: o }); // Ht +const _s = (e, t, n, r, X) => ({ type: "ring", key: e, cx: X, cy: X, r: t, width: n, opacity: r }); // _s(cx/cy 由调用处给 x) +const Oh = (e, t, n, r) => ({ type: "shape", key: e, d: t, transform: n, opacity: r }); // Oh +const XT = (e, t, n, r) => ({ type: "stroke", key: e, d: t, width: n, opacity: r }); // XT + +function makeMorphGenerators(G) { // gE 一族(x=设计画布中心 114.2705) + const x = G.x; + const oE = (e, t, n) => { // dots + const r = [x - G.yd, x + G.yd], o = []; + for (let s = 0; s < 2; s++) { + const i = le((e - s * .12) / (1 - s * .12), 0, 1); + if (i <= .004) continue; + const a = Ye(i), c = Es(i), u = Lh(t, s === 0 ? 0 : 2, e, n, G); + o.push(Ht(`dots.${s}`, x + (r[s] - x) * c, x - u.lift, G.RT * a * u.pop * G.LT, a * u.tone)); + } + return o; + }; + const sE = (e, t) => { // orbit + const n = Ye(e), r = 52 * Es(e), o = 12, s = t.stateTime * 1e3 * .0017; + return Array.from({ length: 5 }, (i, a) => { + const c = s + a * TAU / 5, u = Math.cos(c), l = .5 + .5 * le(u, 0, 1); + return Ht(`orbit.${a}`, x + r * Math.sin(c), x - r * .42 * Math.cos(c), Math.max(o * l * n, .3), le((u + .4) / .6, .18, 1) * n); + }); + }; + const iE = (e, t, n) => { // radar + const r = Ye(e), o = 1300, s = 104; + return Array.from({ length: 3 }, (i, a) => { + const c = Bt(t.stateTime * 1e3 / o + a / 3); + return { type: "ring", key: `radar.${a}`, cx: x, cy: x, r: n + (s - n) * c, width: 3.4 * (1 - c * .55), opacity: r * (1 - c) * .9 }; + }); + }; + const aE = (e, t) => { // progress + const n = Ye(e), r = 62 * Es(e), o = le(t.shotTime * 1e3 / G.jT, 0, 1), s = le(o / .85, 0, 1), + i = { type: "ring", key: "progress.track", cx: x, cy: x, r, width: 5, opacity: n * .16 }; + return s <= 0 ? [i] : [i, { type: "ring", key: "progress.arc", cx: x, cy: x, r, width: 5, opacity: n, arc: { fraction: s, startDeg: -90 } }]; + }; + const cE = (e, t) => { // gather + const n = Ye(e), r = []; + for (let o = 0; o < 5; o++) { + const s = le((t.shotTime * 1e3 / G.qT - o * .09) / .62, 0, 1); + if (s >= 1) continue; + const i = 1 - Math.pow(1 - s, 3), a = o * 2.4 + s * 2.2, c = 96 * (1 - i); + r.push(Ht(`gather.${o}`, x + c * Math.cos(a), x + c * Math.sin(a) * .8, 9 * (.5 + .5 * i) * n, n * le(s * 5, 0, 1) * (1 - i * .25))); + } + return r; + }; + const uE = (e, t) => { // wave + const n = t.stateTime * 1e3, r = 44, o = []; + for (const s of G.BT) { + const i = le((e - Math.abs(s) * .1) / (1 - Math.abs(s) * .1), 0, 1); + if (i <= .004) continue; + const a = Es(i), c = QT(n, s), u = (7 + 9 * le(c, .08, 1)) * Ye(i), l = 6 * le(c, 0, 1) * i; + o.push(Ht(`wave.${s}`, x + s * r * a, x - l, u, i)); + } + return o; + }; + const lE = (e, t) => { // send + const n = Ye(e), r = Bt(t.stateTime * 1e3 / G.Rh), o = le((r - .18) / .55, 0, 1), s = o * o * (.4 + .6 * o), + i = .74, a = -.62, c = []; + if (o > 0 && o < 1) { const f = 108 * s; c.push(Ht("send.packet", x + i * f, x + a * f, 10 * (1 - s * .55) * n, n * (1 - s * s))); } + const u = le((r - .26) / .55, 0, 1), l = u * u * (.4 + .6 * u); + if (o > 0 && u > 0 && u < 1) { const f = 108 * l; c.push(Ht("send.streak", x + i * f, x + a * f, 5 * (1 - l * .6) * n, n * .3 * (1 - l))); } + const d = le((r - .18) / .3, 0, 1); + return d > 0 && d < 1 && c.push({ type: "ring", key: "send.launch", cx: x, cy: x, r: 20 + 34 * Ye(d), width: 2.8 * (1 - d), opacity: n * (1 - d) * .8 }), c; + }; + const dE = (e, t) => { // receive + const n = Ye(e), r = t.stateTime * 1e3, o = JT(Math.floor(r / G.ga)), s = Bt(r / G.ga), + i = le(s / .6, 0, 1), a = 1 - Math.pow(1 - i, 3), c = Math.cos(o), u = Math.sin(o), l = 108 * (1 - a), d = []; + if (i < 1) { + const h = 18 * Math.sin(i * Math.PI) * (1 - a * .7); + d.push(Ht("receive.packet", x + c * l - u * h, x + u * l + c * h, 3.5 + 6.5 * a, n * le(i * 3.5, 0, 1) * (.3 + .7 * a))); + } + const f = le((s - .58) / .32, 0, 1); + return f > 0 && f < 1 && d.push({ type: "ring", key: "receive.absorb", cx: x, cy: x, r: 20 + 26 * Ye(f), width: 2.8 * (1 - f), opacity: n * (1 - f) * .8 }), d; + }; + const fE = (e, t) => { // dock + const n = Ye(e), r = t.stateTime * 1e3, o = 42, s = 1.1, i = []; + for (let a = 0; a < 2; a++) { + const c = le((t.stateTime - (.2 + a * 1.3)) / .9, 0, 1); + if (c <= 0) continue; + const u = 1 - Math.pow(1 - c, 3), l = r * .001 * s + a * Math.PI, d = x + o * Math.sin(l), + f = x + o * .5 * Math.cos(l) + Math.sin(r * .003 + a) * 2, h = x - 120 + a * 30, p = x + 95; + i.push(Ht(`dock.${a}`, h + (d - h) * u, p + (f - p) * u, (7 + 3 * u) * n, n * le(c * 4, 0, 1))); + } + return i; + }; + const hE = (e, t, n, tE) => { // pencil + const r = t.stateTime * 1e3, o = pc(r, G), s2 = (o.rot - 90) * DEG, i = 68, + a = Math.cos(s2) * i, c = Math.sin(s2) * i, u = x + (o.x + a) * e, l = x + (o.y + o.wig * .15 + c) * e, + d = [Oh("pencil.shaft", G.UT, `translate(${u.toFixed(1)} ${l.toFixed(1)}) rotate(${(o.rot * e).toFixed(1)}) scale(${Ye(e).toFixed(3)}) translate(${-x} ${-x})`, le(e * 1.6 - .3, 0, 1))], + f = nE(r, G, tE); + return f.length >= 2 && d.push(XT("pencil.ink", rE(f), 6, le(e * 1.2, 0, 1))), d; + }; + const pE = (e, t) => { // bang + const n = t.stateTime, r = Ye(le(e * 1.1, 0, 1)), o = Math.exp(-(n % 2.2) * 5.5), s = Math.sin(n * 42) * 2.2 * o; + return [Oh("bang.bar", G.KT, `translate(0 ${(-26 - (1 - r) * 70).toFixed(1)}) rotate(${s.toFixed(2)} ${x} ${(x - 74).toFixed(1)}) translate(${x} ${x}) scale(${le(e * 1.2, 0, 1).toFixed(3)}) translate(${-x} ${-x})`, le(e * 1.5 - .2, 0, 1))]; + }; + const mE = (e, t) => { // standby + const n = .5 + .5 * Math.sin(t.stateTime * 1e3 * .0016); + return [Ht("standby.halo", x, x, 26 + 7 * n, Ye(e) * (.06 + .1 * n))]; + }; + return (e, t, n, r, o, tE) => { // gE + switch (e) { + case "dots": return oE(t, n, o); + case "orbit": return sE(t, n); + case "radar": return iE(t, n, r); + case "progress": return aE(t, n); + case "gather": return cE(t, n); + case "wave": return uE(t, n); + case "send": return lE(t, n); + case "receive": return dE(t, n); + case "dock": return fE(t, n); + case "ball": return []; + case "pencil": return hE(t, n, o, tE); + case "bang": return pE(t, n); + case "standby": return mE(t, n); + } + }; +} + +// ============================================================ +// yE:morph 合成器(amount/swap → core 变换 + parts + zoom) +// ============================================================ +const YT = { lift: 0, pop: 1, tone: 1 }; // YT:无 dots 时的中性 lift/pop/tone +const Kx = (e, t, n) => e.map(([r, o], s) => [r + (t[s][0] - r) * n, o + (t[s][1] - o) * n]); // Kx:点列插值 +// Mi:morph 身体环。pencil 的身体环 VT 依赖一套未移植的形状几何 DSL(nh/HT), +// 回退为 96 边形圆环 $h(其余 pencil 动画:轨迹/墨迹/旋转均完整)。 +const Mi = (e, G) => G.$h; +function yE(e, t, G, gE, tE) { // yE(t=显示尺寸,仅用于小尺寸 zoom 阻尼) + const { kind: n, isReducedMotion: r } = e, o = le(e.amount, 0, 1), s = le(e.swap, 0, 1), + i = s < .999 ? e.outgoing : null, + a = j => j === n ? o * s : j === i ? o * (1 - s) : 0, + c = j => j === n ? e.clock : e.outgoingClock, + u = le(o / G._T, 0, 1), + d = n === "pencil" || i === "pencil" ? s >= .999 ? Mi(n, G) : Kx(Mi(i, G), Mi(n, G), Qo(s)) : G.$h, + f = n ? G.Si[n] * s + (i ? G.Si[i] : G.Si[n]) * (1 - s) : 19, + h = a("dots"), p = n === "dots" || i === "dots", + g = p ? Lh(c("dots"), 1, o, r, G) : YT; + let m = p ? 1 + (g.pop - 1) * (h / Math.max(o, .001)) : 1; + const y = a("receive"); + if (y > .004) { + const j = Bt(c("receive").stateTime * 1e3 / G.ga), J = le((j - .58) / .34, 0, 1); + m *= 1 + .11 * Math.sin(J * Math.PI) * y; + } + const w = a("send"); + if (w > .004) { + const j = Bt(c("send").stateTime * 1e3 / G.Rh), J = j < .18 ? -.06 * Math.sin(j / .18 * Math.PI) : 0, + fe = j >= .18 && j < .42 ? .05 * Math.sin((j - .18) / .24 * Math.PI) : 0; + m *= 1 + (J + fe) * w; + } + const S = a("bang"); + S > .004 && (m *= 1 + .04 * Math.exp(-(c("bang").stateTime % 2.2) * 5.5) * S); + let b = 0, v = 0, T = 0; + const A = a("pencil"); + if (A > .004) { + const j = pc(c("pencil").stateTime * 1e3, G); + b += j.x * A, v += (j.y + j.wig * .5) * A, T += j.rot * A; + } + S > .004 && (v += 58 * S); + const F = a("ball"); + F > .004 && (v += eE(c("ball").stateTime) * F); + const N = f / G.x * m, R = a("standby"), + B = R > 0 ? (.28 + .2 * Math.sin(c("standby").stateTime * 1e3 * .0016)) * R : 0, + E = { + scale: 1 - o + N * o, dx: b * o, dy: -g.lift * h + v * o, rotation: T * o, + opacity: (1 - (1 - g.tone) * h) * (1 - B), round: Qo(u), ring: d, + }, + I = 1 - eb(le((t - 44) / 90, 0, 1)), + _ = n == null ? 1 : G.gd[n], O = i ? G.gd[i] : _, + H = 1 + (_ * s + O * (1 - s) - 1) * o * I, L = []; + for (const j of [i, n]) { + if (j == null) continue; + const J = a(j); + J > .004 && L.push(...gE(j, J, c(j), f, r, tE)); + } + return { core: E, isFaceVisible: o < .5, zoom: H, parts: L.filter(j => j.opacity > 0) }; +} + +// ============================================================ +// 身体轮廓 ⇄ morph 环混合(gM 射线求交 / kM 角度重采样 / Th 加权 / SM 圆度混合) +// ============================================================ +const rd = 1e-9, dM = 1e-7, tn = 720; // rd/dM/tn +function hM(e) { return Array.from({ length: e }, (t, n) => n / e * TAU); } // hM +const kE = hM(192); // kE:基础角度采样 +function yM(e, t, n) { // yM:椭圆参数采样(半径归一化) + const [r, o] = t; + return Array.from({ length: n }, (s, i) => { + const a = i / n * TAU, c = Math.cos(a), u = Math.sin(a), + l = r === 0 || o === 0 ? 0 : 1 / Math.hypot(c / r, u / o); + return [e[0] + c * l, e[1] + u * l]; + }); +} +function pM(e, t) { return e.flatMap(n => n.map(([r, o]) => us(t, r, o))); } // pM +function mM(e) { // mM:排序去重角度表 + const t = e.flat().sort((n, r) => n - r); + return t.filter((n, r) => r === 0 || n - t[r - 1] > dM); +} +function us(e, t, n) { const r = Math.atan2(n - e[1], t - e[0]); return r < 0 ? r + TAU : r; } // us +function EM(e, t) { // EM:按角度分桶(tn=720 桶)收集轮廓边 + const n = Array.from({ length: tn }, () => []); + for (const r of e) for (let o = 0; o < r.length; o++) { + const s = r[o], i = r[(o + 1) % r.length], a = us(t, s[0], s[1]); + let c = us(t, i[0], i[1]) - a; + c > Math.PI && (c -= TAU), c < -Math.PI && (c += TAU); + const u = Math.floor((c < 0 ? a + c : a) / TAU * tn) - 1, + l = Math.floor((c < 0 ? a : a + c) / TAU * tn) + 1; + for (let d = u; d <= l; d++) n[(d % tn + tn) % tn].push([s, i]); + } + return n; +} +function PM(e, [t, n], r) { // PM:射线 r 与桶内各边求最近交点距离 + const o = Math.cos(r), s = Math.sin(r); + let i = Number.NaN; + for (const [[a, c], [u, l]] of e[Math.floor(r / TAU * tn) % tn]) { + const d = u - a, f = l - c, h = o * f - s * d; + if (Math.abs(h) < 1e-12) continue; + const p = a - t, g = c - n, m = (p * f - g * d) / h, y = (p * s - g * o) / h; + m >= 0 && y >= -rd && y <= 1 + rd && !(m <= i) && (i = m); + } + return i; +} +function AM(e) { // AM:NaN 角度沿最近有效邻居线性插值 + const t = e.length, n = e.flatMap((r, o) => Number.isNaN(r) ? [] : [o]); + return n.length === 0 ? e.map(() => 0) : e.map((r, o) => { + if (!Number.isNaN(r)) return r; + const s = n.find(c => c > o) ?? n[0] + t, + i = [...n].reverse().find(c => c < o) ?? (n.at(-1) ?? 0) - t, + a = (o - i) / (s - i); + return e[(i + t) % t] * (1 - a) + e[s % t] * a; + }); +} +function gM(e, t, n) { // gM:轮廓点列 → 沿角度表 n 的射线距离轮廓 + const r = EM(e, t), o = AM(n.map(s => PM(r, t, s))); + return n.map((s, i) => [t[0] + Math.cos(s) * o[i], t[1] + Math.sin(s) * o[i]]); +} +function IM(e, t) { // IM:按角度在 {angle,reach} 表上插值 + const n = e.length; + if (n === 0) return 0; + let r = e.findIndex(c => c.angle >= t); + r < 0 && (r = 0); + const o = e[r], s = e[(r - 1 + n) % n], i = (o.angle - s.angle + TAU) % TAU; + if (i === 0) return o.reach; + const a = (t - s.angle + TAU) % TAU / i; + return s.reach + (o.reach - s.reach) * a; +} +function kM(e, t, n) { // kM:任意点环 → 沿角度表 n 的星形重采样 + const r = e.map(([o, s]) => ({ angle: us(t, o, s), reach: Math.hypot(o - t[0], s - t[1]) })) + .sort((o, s) => o.angle - s.angle); + return n.map(o => { + const s = IM(r, o); + return [t[0] + Math.cos(o) * s, t[1] + Math.sin(o) * s]; + }); +} +function Th(e) { // Th:加权平均多个等长轮廓 + const t = e.reduce((r, o) => r + o.weight, 0), [n] = e; + return n == null || t === 0 ? [] : n.outline.map((r, o) => { + let s = 0, i = 0; + for (const { outline: a, weight: c } of e) s += a[o][0] * c, i += a[o][1] * c; + return [s / t, i / t]; + }); +} +function SM(e, t, n) { return e.map(([r, o], s) => [r + (t[s][0] - r) * n, o + (t[s][1] - o) * n]); } // SM:向目标轮廓混合 round 比例 +function ME(e, t, n, r, G) { // ME:morph ring(设计坐标)→ 画布坐标轮廓 + const o = n / G.x, + s = e.core.ring.map(([i, a]) => [t[0] + (i - G.x) * o, t[1] + (a - G.x) * o]); + return kM(s, t, r); +} +function TE(e, t, n, r, G) { // TE:morph core → head 组变换($n/zoom 抵消外层缩放) + const { core: o } = e, s = 1 - t, i = t > 0 ? (o.scale - s) / t : 0; + return { translate: [o.dx * r, o.dy * r], rotation: o.rotation, scale: s + i * (G.$n / n) * t, opacity: o.opacity }; +} +const da = e => e.length === 0 ? "" : `M${e.map(([t, n]) => `${od(t)} ${od(n)}`).join("L")}Z`; // da:点列 → SVG path + +// ============================================================ +// 渲染模型 builder(对应 Fh 分发:sphere → wT 弧线路径;morph → xE 点列路径) +// ============================================================ +function bT(e, t, G) { // bT:sphere 帧 → {center, radius, body 点列, eyes 点列} + const n = e.zoom * t / 2, [r, o] = e.squash, + s = [t / 2 + n * e.offset[0], t / 2 - n * (e.squashAnchor * (1 - o) + e.offset[1])], + i = Math.abs(n), a = ([c, u]) => [s[0] + c * n, s[1] - u * n]; + return { + center: s, radius: i, + body: [yM(s, [Math.abs(r) * i, Math.abs(o) * i], yT)], + eyes: Ch(e, i, G).map(c => xT(c, kT).map(a)), + }; +} +// buildModel:frame → 渲染模型。非 sphere 身体回退为 sphere 几何(警告一次); +// 多身体交叉淡化时各身体形状相同(均 sphere),眼睛取权重最大者(原版的 PE 环混合未移植)。 +function buildModel(e, t, n, G, gE, tE, warnBody) { + const r = t.inkPaint ?? Nr(t.ink), o = t.spotPaint ?? Nr(t.spot), + s = e.morph == null && e.bodies.length === 1 && e.bodies[0].body === "sphere"; + if (s) { + const i = wT(e, t, n, G); + return { + mode: "sphere", size: n, bodyD: i.body, eyesD: i.eyes, glow: i.glow, + inkPaint: r, spotPaint: o, cutout: t.cutout, + }; + } + const i = e.bodies.map(({ body: c, weight: u }) => { + c !== "sphere" && warnBody(c); + return { shape: bT(e, n, G), weight: u }; + }).sort((c, u) => u.weight - c.weight); + const a = i[0]?.shape.center ?? [n / 2, n / 2], u2 = i[0]?.shape.radius ?? 0, + l = mM([kE, ...i.map(({ shape: c }) => pM(c.body, c.center))]), + d = Th(i.map(({ shape: c, weight: u }) => ({ outline: gM(c.body, c.center, l), weight: u }))), + f = e.morph == null ? null : yE(e.morph, n, G, gE, tE), + h = G.$n * n / 2 / G.x; + return { + mode: "morph", size: n, center: a, + bodyPts: f == null || f.core.round === 0 ? d : SM(d, ME(f, a, u2, l, G), f.core.round), + eyesPts: i[0]?.shape.eyes ?? [], + head: f == null || e.morph == null ? null : TE(f, e.morph.amount, e.zoom, h, G), + parts: f?.parts ?? [], markScale: h, zoom: f?.zoom ?? 1, + inkPaint: r, spotPaint: o, cutout: t.cutout, + }; +} + +// ============================================================ +// 程序化装饰图形(GT 圆头竖条 / WT 圆角感叹号)+ 设计画布常量 +// ============================================================ +const GT = (e, t, x) => { // GT(e=宽, t=高):圆头竖条 path + const n = e / 2, r = x - t / 2 + n, o = x + t / 2 - n; + return `M${x - n} ${r}A${n} ${n} 0 0 1 ${x + n} ${r}L${x + n} ${o}A${n} ${n} 0 0 1 ${x - n} ${o}Z`; +}; +const WT = (e, t, n, x) => { // WT(e=上宽, t=下宽, n=高):感叹号条 path + const r = e / 2, o = t / 2, s = x - n / 2, i = x + n / 2; + return `M${x - r} ${s + r}A${r} ${r} 0 0 1 ${x + r} ${s + r}L${x + o} ${i - o}A${o} ${o} 0 0 1 ${x - o} ${i - o}Z`; +}; + +// ============================================================ +// makeConfig:grok-recipes.json → 引擎常量配置 G +// ============================================================ +function makeConfig(R) { + const C = R.constants, M = C.morph, bn = R.expressions, + bb = new Set(R.blinkBlockingExpressions), + wb = new Map(Object.entries(R.expressionAliases)), + gb = new Set(R.expressionNames), + Kf = R.eyes.baseEulerDeg, + shapeNames = R.shapes.names, + lx = new Map(Object.entries(R.shapes.aliases)), + Rx = new Map(R.clips.map(e => [e.name, e])), + x = M.canvasCenter, + $h = Array.from({ length: 96 }, (e, t) => { // $h:96 边形圆环(设计坐标,半径 x) + const n = t / 96 * TAU; + return [x + Math.cos(n) * x, x + Math.sin(n) * x]; + }); + return { + expressions: bn, expressionNames: gb, expressionAliases: wb, blinkBlocking: bb, + eyeBasis: R.eyes.basis, eyeMirror: R.eyes.mirror, baseEulerDeg: Kf, + xr: R.eyes.defaultWidth, kb: R.eyes.pathSegments, yb: R.eyes.spineSamples, + Eb: C.eyes.Eb, Pb: C.eyes.Pb, Ab: C.eyes.Ab, Ib: C.eyes.Ib, + na: C.durations.blink, nb: C.durations.morph, ta: C.durations.lidMorph, + rb: C.durations.blinkHold, ob: C.durations.blinkClose, si: C.durations.blinkOvershoot, + nr: C.ease.nr, tbEase: C.ease.tb, dxEase: C.ease.dx, vxEase: C.ease.vx, + $n: C.runtime.zoomDefault, Sx: C.runtime.lookDuration, Mx: C.runtime.maxAdvance, + Tx: C.runtime.bodySpringFreq, Ex: C.runtime.bodyWeightEps, Px: C.runtime.morphHideEyes, + Ax: C.runtime.gazeSnap, Yb: C.gaze.Yb, Zb: C.gaze.Zb, gazeShapeMul: C.gaze.Qb, + stateMorphMap: M.stateMap, morphShotSet: new Set(["progress", "spawning"]), + morphShotSeconds: { progress: 2.5, spawning: 2 }, // sx / ix + trackAliases: new Map(Object.entries(R.aliases.tracks)), + bodyAlias(e) { const t = lx.get(e) ?? e; return shapeNames.includes(t) ? t : "sphere"; }, // hx + restClip: { // cl:rest 帧(仅 baseEulerDeg 旋转) + duration: C.runtime.restClipDuration, body: null, squashAnchor: 0, restTime: 0, + inspect: {}, position: [], rotation: [{ t: 0, v: Kf, ease: C.ease.nr }], + squash: [], zoom: [], lids: [], expression: [], + }, + clipByName(e) { // $x + const t = Rx.get(e); + if (t == null) throw new Error(`No motion recipe named ${e}`); + return t; + }, + Tb: e => !bb.has(e) && !bn[e].some(jt), // Tb:该表情是否允许被眨眼 lids 覆盖 + Lr: e => bb.has(e), // Lr + Mb: e => { const t = e.trim().toLowerCase(), n = wb.get(t) ?? t; return gb.has(n) ? n : "neutral"; }, // Mb:表情名归一 + states: R.states, + x, IT: M.markOffset, _T: M.weightsFull, + FT: M.liftPeriodMs, NT: M.liftAmp, $T: M.zoomFloor, OT: M.zoomFloorAmt, LT: M.liftBoost, + RT: M.discR, yd: M.dotsGap, jT: M.progressShotMs, qT: M.gatherShotMs, BT: M.waveOffsets, + Rh: M.sendPeriodMs, ga: M.receivePeriodMs, ds: M.pencilPeriodMs, Mr: M.pencilDrawFrac, + Co: M.pencilTrailMs, zT: M.trailMinStep, DT: M.trailSampleMsPerFrame, + gd: M.kindZoom, Si: M.baseRadius, + UT: GT(30, 88, x), KT: WT(30, 17, 96, x), $h, + }; +} + +// ============================================================ +// DOM 渲染器:固定骨架 + 属性级更新(无 innerHTML / 无节点重建) +// ============================================================ +const SVG_NS = "http://www.w3.org/2000/svg"; +function createRenderer(doc, uid, S, G) { + const cache = new WeakMap; + const setA = (n, k, v) => { + let m = cache.get(n); + m === void 0 && cache.set(n, m = {}); + if (m[k] === v) return; + m[k] = v; + v == null ? n.removeAttribute(k) : n.setAttribute(k, v); + }; + const mk = (tag, attrs = {}, parent) => { + const n = doc.createElementNS(SVG_NS, tag); + for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v); + return parent ? parent.appendChild(n) : n; + }; + const svg = mk("svg", { + id: uid, width: S, height: S, viewBox: `0 0 ${S} ${S}`, + role: "img", "aria-hidden": "true", + style: "display:block;overflow:visible", + }); + const defs = mk("defs", {}, svg); + // cutout 用 mask / 非 cutout 用 clipPath(morph 模式) + const mask = mk("mask", { id: `${uid}-holes`, maskUnits: "userSpaceOnUse", x: 0, y: 0, width: S, height: S }, defs); + mk("rect", { width: S, height: S, fill: "#fff" }, mask); + const maskEyes = mk("path", { fill: "#000" }, mask); + const clip = mk("clipPath", { id: `${uid}-body` }, defs); + const clipBody = mk("path", {}, clip); + // glow 渐变(懒创建) + let gBody = null, gBodyStops = null, gHalo = null, gHaloStops = null; + const ensureBodyGrad = () => { + if (gBody) return; + gBody = mk("radialGradient", { id: `${uid}-gbody`, gradientUnits: "userSpaceOnUse", cx: 0, cy: 0, r: 1, fx: 0, fy: 0 }, defs); + gBodyStops = Array.from({ length: ld + 1 }, () => mk("stop", {}, gBody)); + }; + const ensureHaloGrad = () => { + if (gHalo) return; + gHalo = mk("radialGradient", { id: `${uid}-ghalo`, gradientUnits: "userSpaceOnUse", cx: 0, cy: 0, r: 1, fx: 0, fy: 0 }, defs); + gHaloStops = Array.from({ length: dd + 1 }, () => mk("stop", {}, gHalo)); + }; + const applyGrad = (grad, stops, spec) => { + setA(grad, "gradientTransform", spec.transform); + for (let i = 0; i < stops.length; i++) { + const st = spec.stops[i]; + setA(stops[i], "offset", String(st.offset)); + setA(stops[i], "stop-color", st.color); + setA(stops[i], "stop-opacity", String(st.opacity)); + } + }; + const zoomG = mk("g", {}, svg); + const partsG = mk("g", {}, zoomG); + const headG = mk("g", {}, zoomG); + const haloP = mk("path", { "fill-rule": "evenodd" }, headG); + const bodyP = mk("path", { "fill-rule": "evenodd" }, headG); + const eyesP = mk("path", {}, headG); + // parts 节点池(按 key 复用) + const pool = new Map; + const renderParts = (parts, inkPaint) => { + const seen = new Set; + for (const p of parts) { + seen.add(p.key); + let rec = pool.get(p.key); + const tag = p.type === "disc" || p.type === "ring" ? "circle" : "path"; + if (rec === void 0 || rec.type !== p.type) { + rec?.node.remove(); + rec = { type: p.type, node: mk(tag) }; + pool.set(p.key, rec); + } + const n = partsG.appendChild(rec.node); // 维持数组顺序 = z-order + if (p.type === "disc") { + setA(n, "cx", Se(p.cx)); setA(n, "cy", Se(p.cy)); setA(n, "r", Se(p.r)); + setA(n, "fill", inkPaint); + setA(n, "stroke", null); setA(n, "stroke-width", null); + setA(n, "stroke-dasharray", null); setA(n, "transform", null); setA(n, "d", null); + } else if (p.type === "ring") { + setA(n, "cx", Se(p.cx)); setA(n, "cy", Se(p.cy)); setA(n, "r", Se(p.r)); + setA(n, "fill", "none"); setA(n, "stroke", inkPaint); setA(n, "stroke-width", Se(p.width)); + if (p.arc != null) { + const C2 = 2 * Math.PI * p.r; + setA(n, "stroke-dasharray", `${Se(C2 * p.arc.fraction)} ${Se(C2)}`); + setA(n, "transform", `rotate(${Se(p.arc.startDeg)} ${Se(p.cx)} ${Se(p.cy)})`); + } else setA(n, "stroke-dasharray", null), setA(n, "transform", null); + setA(n, "d", null); + } else if (p.type === "shape") { + setA(n, "d", p.d); setA(n, "transform", p.transform ?? null); setA(n, "fill", inkPaint); + setA(n, "stroke", null); setA(n, "stroke-width", null); setA(n, "stroke-dasharray", null); + setA(n, "cx", null); setA(n, "cy", null); setA(n, "r", null); + } else { // stroke + setA(n, "d", p.d); setA(n, "fill", "none"); setA(n, "stroke", inkPaint); + setA(n, "stroke-width", Se(p.width)); + setA(n, "stroke-linecap", "round"); setA(n, "stroke-linejoin", "round"); + setA(n, "transform", null); setA(n, "stroke-dasharray", null); + setA(n, "cx", null); setA(n, "cy", null); setA(n, "r", null); + } + setA(n, "opacity", p.opacity < 1 ? Se(p.opacity, 3) : null); + } + for (const [k, rec] of pool) seen.has(k) || (rec.node.remove(), pool.delete(k)); + }; + const render = model => { + if (model.mode === "sphere") { + renderParts(EMPTY_PARTS, model.inkPaint); + setA(partsG, "transform", null); + setA(headG, "transform", null); setA(headG, "opacity", null); + setA(zoomG, "transform", null); + const glow = model.glow; + if (glow?.halo != null) { + ensureHaloGrad(); + applyGrad(gHalo, gHaloStops, glow.halo.gradient); + setA(haloP, "d", glow.halo.d); setA(haloP, "fill", `url(#${uid}-ghalo)`); + } else setA(haloP, "d", ""), setA(haloP, "fill", null); + if (glow != null) { + ensureBodyGrad(); + applyGrad(gBody, gBodyStops, glow.body); + setA(bodyP, "fill", `url(#${uid}-gbody)`); + } else setA(bodyP, "fill", model.inkPaint); + setA(bodyP, "d", model.bodyD); + setA(bodyP, "mask", null); + setA(eyesP, "d", model.eyesD); + setA(eyesP, "fill", model.eyesD === "" ? null : model.spotPaint); + setA(eyesP, "clip-path", null); + return; + } + // morph 模式(原版 xE 丢弃 glow) + setA(haloP, "d", ""); setA(haloP, "fill", null); + const bodyD = da(model.bodyPts), eyesD = model.eyesPts.map(da).join(""); + setA(bodyP, "d", bodyD); setA(bodyP, "fill", model.inkPaint); + if (eyesD !== "" && model.cutout) { + setA(maskEyes, "d", eyesD); + setA(bodyP, "mask", `url(#${uid}-holes)`); + setA(eyesP, "d", ""); setA(eyesP, "fill", null); setA(eyesP, "clip-path", null); + } else if (eyesD !== "") { + setA(bodyP, "mask", null); + setA(clipBody, "d", bodyD); + setA(eyesP, "d", eyesD); setA(eyesP, "fill", model.spotPaint); + setA(eyesP, "clip-path", `url(#${uid}-body)`); + } else { + setA(bodyP, "mask", null); + setA(eyesP, "d", ""); setA(eyesP, "fill", null); setA(eyesP, "clip-path", null); + } + const d = model.head; + if (d == null) setA(headG, "transform", null), setA(headG, "opacity", null); + else { + const [cx, cy] = model.center; + setA(headG, "transform", + `translate(${Se(cx + d.translate[0])} ${Se(cy + d.translate[1])}) rotate(${Se(d.rotation)}) scale(${Se(d.scale, 5)}) translate(${Se(-cx)} ${Se(-cy)})`); + setA(headG, "opacity", d.opacity < 1 ? Se(d.opacity, 3) : null); + } + if (model.parts.length === 0) renderParts(EMPTY_PARTS, model.inkPaint), setA(partsG, "transform", null); + else { + setA(partsG, "transform", + `translate(${Se(model.size / 2)} ${Se(model.size / 2)}) scale(${Se(model.markScale, 5)}) translate(${-G.x} ${-G.x})`); + renderParts(model.parts, model.inkPaint); + } + if (model.zoom === 1) setA(zoomG, "transform", null); + else { + const p = model.size / 2 + (G.IT - G.x) * model.markScale; + setA(zoomG, "transform", + `translate(${Se(p)} ${Se(p)}) scale(${Se(model.zoom, 5)}) translate(${Se(-p)} ${Se(-p)})`); + } + }; + return { svg, render }; +} +const EMPTY_PARTS = []; + +// ============================================================ +// createGrokBot:宿主装配(rAF 循环 / 休眠唤醒 / 指针跟随 / 静态模式) +// ============================================================ +const Ti = 48, OE = 60, LE = .1; // Ti:compact 阈值;OE/LE:静态 settle 步数/步长 +function NE(e) { // NE:FNV-1a 字符串 hash + let t = 2166136261; + for (let n = 0; n < e.length; n++) t = Math.imul(t ^ e.charCodeAt(n), 16777619); + return t >>> 0; +} +const FE = () => typeof window > "u" || typeof window.matchMedia != "function" + ? !1 : window.matchMedia("(prefers-reduced-motion: reduce)").matches; // FE + +let gpCounter = 0; +export function createGrokBot(container, opts = {}) { + const doc = container.ownerDocument, win = doc.defaultView, + uid = `gp${++gpCounter}`, + size = opts.size ?? 320, + theme = { ink: [0, 0, 0], spot: [0, 0, 0], cutout: !0, ...opts.theme }, + seedNum = typeof opts.seed == "string" ? NE(opts.seed) : opts.seed ?? 1, + isStatic = (opts.staticMode ?? !1) || (opts.reducedMotion ?? FE()), + maxFps = Math.max(1, opts.maxFps ?? 60), + frozenAt = opts.frozenAt; + + let G = null, rt = null, nx = null, renderer = null, gE = null, trail = null, + ready = !1, destroyed = !1, loadError = null; + const pending = []; + + // ---------- rAF 循环 ---------- + let rafId = 0, lastT = 0, lastTick = 0, phase = 0, running = !1, inView = !0, io = null; + const frameDur = 1e3 / maxFps; + const tick = now => { + rafId = 0; + if (doc.hidden || !inView) { stopLoop(); return; } + phase += Math.max(0, now - lastTick); + lastTick = now; + if (phase + .5 < frameDur) { rafId = win.requestAnimationFrame(tick); return; } + phase %= frameDur; + const dt = (now - lastT) / 1e3; + lastT = now; + const start = opts.onFrame ? win.performance.now() : 0; + nx.advance(dt), rt.advance(dt); + const advanceMs = opts.onFrame ? win.performance.now() - start : 0; + renderFrame(dt * 1e3, advanceMs); + if (!rt.isPlaying && rt.isSettled) { running = !1; return; } + rafId = win.requestAnimationFrame(tick); + }; + const wake = () => { + if (running || doc.hidden || !inView || destroyed || !ready || isStatic || frozenAt != null) return; + running = !0, lastTick = lastT = win.performance.now(), phase = frameDur; + rafId = win.requestAnimationFrame(tick); + }; + const stopLoop = () => { + running = !1; + rafId && (win.cancelAnimationFrame(rafId), rafId = 0); + }; + const onVisibilityChange = () => doc.hidden ? stopLoop() : wake(); + const renderFrame = (intervalMs, advanceMs = 0) => { + const begin = opts.onFrame ? win.performance.now() : 0; + const model = buildModel(rt.frame(), theme, size, G, gE, trail, warnBody); + const engineMs = opts.onFrame ? win.performance.now() - begin + advanceMs : 0; + renderer.render(model); + if (opts.onFrame) opts.onFrame({ intervalMs, engineMs, domMs: win.performance.now() - begin - (engineMs - advanceMs) }); + }; + const warnedBodies = new Set; + const warnBody = b => { + warnedBodies.has(b) || (warnedBodies.add(b), + console.warn(`[grok-player] 非 sphere 身体 "${b}" 未移植,回退为 sphere 渲染`)); + }; + + // ---------- 指针跟随 ---------- + let followOn = !1; + const onPointerMove = e => { + const rect = renderer.svg.getBoundingClientRect(); + rt.setGaze(ex(rect, { x: e.clientX, y: e.clientY })); + wake(); + }; + const setFollow = v => { + if (v === followOn) return; + followOn = v; + rt?.setGazeAlways(v); + v ? win.addEventListener("pointermove", onPointerMove, { passive: !0 }) + : (win.removeEventListener("pointermove", onPointerMove), rt?.setGaze(null)); + }; + + // ---------- 初始化 ---------- + const dataPromise = opts.data != null + ? Promise.resolve(opts.data) + : fetch(opts.recipesUrl ?? new URL("./grok-recipes.json", import.meta.url)) + .then(r => { if (!r.ok) throw new Error(`grok-recipes.json HTTP ${r.status}`); return r.json(); }); + const readyPromise = dataPromise.then(recipes => { + if (destroyed) return; + G = makeConfig(recipes); + gE = makeMorphGenerators(G); + trail = makeTrail(G); + rt = createRuntime(G, { zoomMul: opts.zoomMul ?? 1, isGazeAlways: opts.follow ?? opts.gazeAlways ?? !1 }); + nx = createStateMachine(G, rt, opts.state ?? "idle", { + seed: seedNum, + isCompact: opts.compact ?? size < Ti, + isResting: isStatic, + firstPhase: opts.firstPhase, + }); + opts.glow && rt.setGlow(opts.glow); + renderer = createRenderer(doc, uid, size, G); + container.appendChild(renderer.svg); + ready = !0; + if (frozenAt != null) { // 确定性采样:1/60 步进到指定时刻渲染单帧,不起 rAF + const dt = 1 / 60; + for (let t = 0; t < frozenAt - 1e-9; t += dt) nx.advance(dt), rt.advance(dt); + renderFrame(); + } else if (isStatic) { // 静态(含 prefers-reduced-motion):pause + seek(0) + 60×0.1 settle 一帧 + rt.pause(), rt.seek(0); + for (let i = 0; i < OE; i++) rt.advance(LE); + renderFrame(); + } else { + renderFrame(); + if (typeof IntersectionObserver < "u") { + io = new IntersectionObserver(entries => { + inView = entries.at(-1)?.isIntersecting ?? !0; + inView ? wake() : stopLoop(); + }); + io.observe(renderer.svg); + } + doc.addEventListener("visibilitychange", onVisibilityChange); + wake(); + } + // 修复:opts.follow 此前只透传了 isGazeAlways,从未挂 pointermove 监听 + if (opts.follow && !isStatic && frozenAt == null) setFollow(true); + for (const f of pending) f(); + pending.length = 0; + }).catch(err => { loadError = err; console.error("[grok-player] 数据加载失败:", err); }); + + // whenReady:未 ready 时排队,ready 后直调;必须透传参数(修复 setState('thinking') 变 setState(undefined) 被静默丢弃) + const whenReady = f => (...args) => { if (!destroyed) (ready ? f(...args) : pending.push(() => f(...args))); }; + const staticRefresh = () => { // 静态模式下 API 变更后重新 settle 一帧 + if (!ready) return; + for (let i = 0; i < OE; i++) nx.advance(LE), rt.advance(LE); + renderFrame(); + }; + const after = () => { isStatic || frozenAt != null ? staticRefresh() : wake(); }; + + return { + ready: readyPromise, + get error() { return loadError; }, + get state() { return nx?.state ?? opts.state ?? "idle"; }, + get clip() { return nx?.clip ?? null; }, + get el() { return renderer?.svg ?? null; }, + setState: whenReady(v => { nx.setState(v); after(); }), + setExpression: whenReady((name, o = {}) => { rt.setExpression(name, { isThroughBlink: o.throughBlink ?? !1 }); after(); }), + blink: whenReady(() => { rt.blink(); after(); }), + look: whenReady(dir => { rt.look(dir); after(); }), + poke: whenReady((kind, turns = 1) => { isStatic || (rt.poke(kind, { turns }), after()); }), + setGaze: whenReady(v => { isStatic || (rt.setGaze(v), after()); }), + setFollow: whenReady(v => { setFollow(v); after(); }), + setCompact: whenReady(v => { nx.setCompact(v); after(); }), + setResting: whenReady(v => { nx.setResting(v); after(); }), + setEmphasis: whenReady(v => { nx.setEmphasis(v); after(); }), + setGlow: whenReady(g => { rt.setGlow(g); after(); }), + // 教学用调试快照:当前状态 / clip / 播放头 / morph 弹簧实测值(解析页曲线可视化用) + debugFrame() { + if (!ready || !rt || !nx) return null; + return { state: nx.state, clip: nx.clip, time: rt.time, duration: rt.duration, morph: rt.frame().morph }; + }, + // 逐帧步进(仅 frozenAt 确定性模式):推进 n 帧(1/60s)并重渲染一帧 + step(n = 1) { + if (!ready || destroyed || frozenAt == null) return; + const dt = 1 / 60; + for (let i = 0; i < n; i++) nx.advance(dt), rt.advance(dt); + renderFrame(); + }, + destroy() { + if (destroyed) return; + destroyed = !0; + stopLoop(); + io?.disconnect(); + doc.removeEventListener("visibilitychange", onVisibilityChange); + followOn && win.removeEventListener("pointermove", onPointerMove); + pending.length = 0; + renderer?.svg.remove(); + }, + }; +} +export { makeConfig, createRuntime, createStateMachine, buildModel, makeMorphGenerators, makeTrail, createRenderer }; +// 教学演示复用:原版球面眼睛数学(lb 欧拉→四元数 / ir 四元数→3×3 / Et 矩阵乘向量 / +// ST 眼睛描述符→球面裁剪环 / xT 弧线细分点列,kT 为其步长) +export { lb, ir, Et, ST, xT, kT }; diff --git a/src/web-ui/src/flow_chat/components/grok-live/grok-recipes.json b/src/web-ui/src/flow_chat/components/grok-live/grok-recipes.json new file mode 100644 index 0000000000..aca363d542 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/grok-live/grok-recipes.json @@ -0,0 +1,8309 @@ +{ + "meta": { + "source": "index.eager-platform-DUN5VkaE.js", + "sha256": "560912e771f4989101d04ad06dca024cd1c0513cbdcad122222d9a6bd1df1183", + "extractedAt": "2026-10-12T09:25:57.844Z", + "tool": "avatar-explainer/tools/extract-recipes.mjs", + "note": "字段名保持 bundle 混淆原样;坐标/角度等单位见播放语义文档。" + }, + "clips": [ + { + "name": "Blocked_A", + "duration": 2.12, + "body": "sphere", + "squashAnchor": 0, + "workArea": { + "start": 0, + "end": 2.1 + }, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "position": [ + { + "t": 0, + "v": [ + 0, + 0 + ], + "ease": [ + 0.7968976121829217, + 0.023358873568246466, + 0.25805236576790014, + 0.9399472349279558 + ] + }, + { + "t": 0.38639855209383034, + "v": [ + 0.020362603567309623, + 0.0321716960933944 + ], + "ease": [ + 0.7968976121829217, + 0.023358873568246466, + 0.25805236576790014, + 0.9399472349279558 + ] + }, + { + "t": 0.7460619029579755, + "v": [ + -0.13200621755876568, + 0.04928491306786316 + ], + "ease": [ + 0.16206624605678288, + 0.6573718201333453, + 0.4776581771164943, + 0.9890940068909632 + ] + }, + { + "t": 0.8660990266653926, + "v": [ + -0.0027221809712659807, + 0.05083886406795958 + ], + "ease": [ + 0.34720720728860055, + -0.008777460320222181, + 0.2695010548130348, + 1.02521711915982 + ] + }, + { + "t": 1.0493565888199954, + "v": [ + -0.12038936798795193, + 0.04235181137057312 + ], + "ease": [ + 0.2773807176656155, + 0.29281356631792965, + 0.35341088328075676, + 1.0737667821535892 + ] + }, + { + "t": 1.2986659810851888, + "v": [ + -0.0027221809712659807, + 0.05083886406795958 + ], + "ease": [ + 0.34720720728860055, + -0.008777460320222181, + 0.2695010548130348, + 1.02521711915982 + ] + }, + { + "t": 1.7569209127327317, + "v": [ + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "rotation": [ + { + "t": 0, + "v": [ + 0, + 0, + 0 + ], + "ease": [ + 0.8199958981940145, + -0.1753438779653778, + 0.35490544014951186, + 0.958837837092147 + ] + }, + { + "t": 0.38639855209383034, + "v": [ + 6.596421550520286, + 26.266588805679774, + 6.122577979110701 + ], + "ease": [ + 0.8199958981940145, + -0.1753438779653778, + 0.35490544014951186, + 0.958837837092147 + ] + }, + { + "t": 0.7460619029579755, + "v": [ + 4.03072349953808, + -14.070821783965659, + -8.088521350792867 + ], + "ease": [ + 0.16206624605678288, + 0.6573718201333453, + 0.4776581771164943, + 0.9890940068909632 + ] + }, + { + "t": 0.8660990266653926, + "v": [ + 10.306252195330941, + 13.969026095064219, + 7.524804229298955 + ], + "ease": [ + 0.34720720728860055, + -0.008777460320222181, + 0.2695010548130348, + 1.02521711915982 + ] + }, + { + "t": 1.0493565888199954, + "v": [ + 8.006914468651756, + -9.270764174998455, + -8.505309845875193 + ], + "ease": [ + 0.2773807176656155, + 0.29281356631792965, + 0.35341088328075676, + 1.0737667821535892 + ] + }, + { + "t": 1.2986659810851888, + "v": [ + 10.306252195330941, + 13.969026095064219, + 7.524804229298955 + ], + "ease": [ + 0.34720720728860055, + -0.008777460320222181, + 0.2695010548130348, + 1.02521711915982 + ] + }, + { + "t": 1.7569209127327317, + "v": [ + 0, + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "scale": [ + { + "t": 0, + "v": [ + 0.6 + ], + "ease": [ + 0.42, + 0, + 0.06944264684998447, + 0.9679740678629829 + ] + }, + { + "t": 0.38639855209383034, + "v": [ + 0.6 + ], + "ease": [ + 0.42, + 0, + 0.06944264684998447, + 0.9679740678629829 + ] + }, + { + "t": 1.1494959879909783, + "v": [ + 0.5 + ], + "ease": [ + 0.7910272291113791, + -0.026550296201944556, + 0.19732842008291107, + 1 + ] + }, + { + "t": 1.900275655021834, + "v": [ + 0.6 + ], + "ease": [ + 0.42, + 0, + 0.06944264684998447, + 0.9679740678629829 + ] + } + ], + "expression": [ + { + "t": 0.46926065284592233, + "v": "confused", + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 1.606008694397346, + "v": "neutral", + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ] + } + }, + { + "name": "Blocked_B", + "duration": 3, + "body": "sphere", + "squashAnchor": 0, + "workArea": { + "start": 0, + "end": 3 + }, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "position": [ + { + "t": 1.2593574149757256, + "v": [ + 0.004259404064870088, + -0.22320548763819217 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "rotation": [ + { + "t": 1.2593574149757256, + "v": [ + 22.38040777312499, + -0.11374925549943268, + -1.3254543924838493 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "expression": [ + { + "t": 0, + "v": "sad", + "ease": [ + 0.8458217358299596, + 0.40487753378378377, + 0.13769926619433198, + 0.9154349662162162 + ] + } + ] + } + }, + { + "name": "Blocked_C", + "duration": 1.4, + "body": "sphere", + "squashAnchor": 0, + "workArea": { + "start": 0, + "end": 1.3929879324776784 + }, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "position": [ + { + "t": 0, + "v": [ + -0.10159994888361161, + 0.0005766171900318479 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.6816231863839288, + "v": [ + 0.06829453998737205, + 0.0005766171900318479 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 1.3929879324776784, + "v": [ + -0.10159994888361161, + 0.0005766171900318479 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "rotation": [ + { + "t": 0, + "v": [ + 22.100629997955743, + -15.657630964434397, + -4.681428255949899 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.6816231863839288, + "v": [ + 21.919069173693785, + 11.388653232575262, + 0.7382242020910257 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 1.3929879324776784, + "v": [ + 22.100629997955743, + -15.657630964434397, + -4.681428255949899 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "expression": [ + { + "t": 0, + "v": "sleepy", + "ease": [ + 0.8458217358299596, + 0.40487753378378377, + 0.13769926619433198, + 0.9154349662162162 + ] + } + ] + } + }, + { + "name": "Bodymoves_A", + "duration": 5, + "body": "clover", + "squashAnchor": 0, + "workArea": null, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "position": [ + { + "t": 0, + "v": [ + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.3316869300911854, + "v": [ + -0.1, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.6673157294832825, + "v": [ + -0.1, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 1, + "v": [ + 0.1, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 1.3356762917933132, + "v": [ + 0.1, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 1.654302811550152, + "v": [ + 0, + 0.1 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 2, + "v": [ + 0, + 0.1 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 2.329549772036474, + "v": [ + 0, + -0.1 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 2.667601683029453, + "v": [ + 0, + -0.1 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 2.9985974754558207, + "v": [ + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "rotation": [ + { + "t": 0, + "v": [ + 0, + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.3316869300911854, + "v": [ + 0, + -20, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.6673157294832825, + "v": [ + 0, + -20, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 1, + "v": [ + 0, + 20, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 1.3356762917933132, + "v": [ + 0, + 20, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 1.654302811550152, + "v": [ + -20, + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 2, + "v": [ + -20, + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 2.329549772036474, + "v": [ + 20, + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 2.667601683029453, + "v": [ + 20, + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 2.9985974754558207, + "v": [ + 0, + 0, + 0 + ], + "ease": [ + 0.7340521114106021, + 0, + 0.024581648697214575, + 1 + ] + }, + { + "t": 4.546161721267454, + "v": [ + 0, + 360, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ] + } + }, + { + "name": "Done_A", + "duration": 3.7, + "body": "sphere", + "squashAnchor": -1, + "workArea": { + "start": 0.07228455190755256, + "end": 2.4415076840284984 + }, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "expression": [ + { + "t": 0.5404427256792492, + "v": "happy", + "ease": [ + 0.9601942054655871, + -0.052470439189189255, + 0.20804339574898786, + 0.9135346283783784 + ] + }, + { + "t": 1.1009640885188103, + "v": "neutral", + "ease": [ + 0.9601942054655871, + -0.052470439189189255, + 0.20804339574898786, + 0.9135346283783784 + ] + } + ], + "squash": [ + { + "t": 0.026170163234005256, + "v": [ + 1, + 1 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.1712334301051709, + "v": [ + 1.095, + 0.95 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.2519479897020158, + "v": [ + 1, + 1.1 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.5890803023663453, + "v": [ + 1, + 1 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.7128174264881666, + "v": [ + 1, + 1 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.7669564766634516, + "v": [ + 1.095, + 0.95 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.9897759640666083, + "v": [ + 1, + 1.05 + ], + "ease": [ + 0.42, + 0, + 0.8951431517668547, + 0.3407570422535186 + ] + }, + { + "t": 1.2017846598233752, + "v": [ + 1, + 1 + ], + "ease": [ + 0.31159929257512675, + 0.38869681601739836, + 0.58, + 1 + ] + }, + { + "t": 1.2442602578454727, + "v": [ + 1.02, + 0.95 + ], + "ease": [ + 0.42, + 0, + 0, + 0.9636255030181098 + ] + }, + { + "t": 1.3707862721816209, + "v": [ + 1, + 1.05 + ], + "ease": [ + 0.42, + -0.06206202263065829, + 0.58, + 1 + ] + }, + { + "t": 1.5659785175305556, + "v": [ + 1, + 1.05 + ], + "ease": [ + 0.42, + -0.06206202263065829, + 0.58, + 1 + ] + }, + { + "t": 1.7225242861726335, + "v": [ + 1, + 1 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "rotation": [ + { + "t": 0.07894117002629274, + "v": [ + 0, + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.5890803023663453, + "v": [ + -30.114678799869385, + -0.3481515921755908, + 14.22102163595976 + ], + "ease": [ + 0.7300325174456456, + -0.12405425024558042, + 0.3365835061031919, + 0.8017889379937657 + ] + }, + { + "t": 1.1421861305872043, + "v": [ + -22.577941336944804, + 6.412920843838687, + -21.655639323327193 + ], + "ease": [ + 0.583298559499341, + -0.33760664743211344, + 0.32045494511798944, + 1.0963542960860653 + ] + }, + { + "t": 1.9672542725679227, + "v": [ + -2.0781042925060573, + 2.7701174100260233, + -1.1097245079483975 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 2.265132022348826, + "v": [ + 0, + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "position": [ + { + "t": 0.21523060911481157, + "v": [ + 0, + 0 + ], + "ease": [ + 0.26223486988161915, + 0.6947245975855132, + 0.19480556164846297, + 0.9038661358145291 + ] + }, + { + "t": 0.4114390589896057, + "v": [ + 0.00004251034064187152, + 0.2658889947011273 + ], + "ease": [ + 0.6174717437059508, + -0.1285535110701914, + 0.9558063166458642, + 1.4558335539794296 + ] + }, + { + "t": 0.7733546480351897, + "v": [ + 0, + 0.0027633019963448496 + ], + "ease": [ + 0.09538195235865887, + 0.5878716966122386, + 0.07993556895591414, + 0.9424688534678969 + ] + }, + { + "t": 0.9897759640666083, + "v": [ + 0.00004251034064187152, + 0.2724621969461928 + ], + "ease": [ + 0.8363729761758775, + -0.06230462217533443, + 0.9982539187302559, + 1.5 + ] + }, + { + "t": 1.2463303718735304, + "v": [ + 0, + 0.004098894994698572 + ], + "ease": [ + 0.0027852407545025976, + 0.747585125427439, + 0.2902850468489716, + 1.075893533235889 + ] + }, + { + "t": 1.4277545322137832, + "v": [ + 0.00004251034064187152, + 0.20927514060819213 + ], + "ease": [ + 0.6418193857854623, + 0.06919682671194852, + 0.999114846102708, + 1.0051682084254565 + ] + }, + { + "t": 1.6154571730378309, + "v": [ + 0, + -0.0012722231452485869 + ], + "ease": [ + 0.1439324608778934, + 0.5569463954729904, + 0.40675790714828725, + 0.8986649812795062 + ] + }, + { + "t": 1.6919913573024103, + "v": [ + 0.00004251034064187152, + 0.12826705419287673 + ], + "ease": [ + 0.7357127354801332, + -0.23608339420075217, + 0.998983119560359, + 1.0241908800770656 + ] + }, + { + "t": 1.838861098534871, + "v": [ + 0, + -0.00406003797139351 + ], + "ease": [ + 0.16778668894268609, + 0.5982980173372091, + 0.2604597520439014, + 0.8844972998628613 + ] + }, + { + "t": 1.8667112801287638, + "v": [ + 0.00004251034064187152, + 0.07903432452816228 + ], + "ease": [ + 0.4685028760838894, + -0.42953625372100485, + 0.9202731953835559, + 0.8525061773543536 + ] + }, + { + "t": 1.9472873681398353, + "v": [ + 0, + -0.006228275916527491 + ], + "ease": [ + 0.0027852407545025976, + 1.5, + 0.48865476645304945, + 0.9551260679225434 + ] + } + ] + } + }, + { + "name": "Done_B", + "duration": 1.8, + "body": "sphere", + "squashAnchor": -1, + "workArea": null, + "inspect": { + "bias": -1, + "follow": false + }, + "tracks": { + "expression": [ + { + "t": 0.1, + "v": "happy", + "ease": [ + 0.4, + 0.22, + 0.28, + 1.34 + ] + } + ], + "squash": [ + { + "t": 0, + "v": [ + 1, + 1 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.24, + "v": [ + 1.12, + 0.88 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.46, + "v": [ + 0.93, + 1.09 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.68, + "v": [ + 1.04, + 0.97 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.92, + "v": [ + 1, + 1 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ] + } + }, + { + "name": "Done_C", + "duration": 2, + "body": "sphere", + "squashAnchor": -1, + "workArea": null, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "expression": [ + { + "t": 0, + "v": "happy", + "ease": [ + 0.7416814271255061, + 0.03220016891891886, + 0.26459703947368424, + 1.0216427364864864 + ] + }, + { + "t": 1.1902334624750293, + "v": "neutral", + "ease": [ + 0.8458217358299596, + 0.40487753378378377, + 0.13769926619433198, + 0.9154349662162162 + ] + } + ], + "position": [ + { + "t": 0, + "v": [ + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.4073342890672376, + 1.018190339792799 + ] + }, + { + "t": 0.3798103165938864, + "v": [ + -0.1054748164006256, + 0.1247799599228919 + ], + "ease": [ + 0.6446428602987402, + 0.013555142906648983, + 0.26273657853993304, + 1.0464437149441994 + ] + }, + { + "t": 0.9426716022659842, + "v": [ + 0.10617803719053583, + 0.1550632852223643 + ], + "ease": [ + 0.8992655949641883, + 0.06226468791954258, + 0.6897429932929974, + 1.0606800225733635 + ] + }, + { + "t": 1.3822867688368345, + "v": [ + 0.011140244111415301, + -0.17250079856992764 + ], + "ease": [ + 0.5369766709678977, + 0.09639530999237222, + 0.4242841447981972, + 0.8655803895199093 + ] + }, + { + "t": 1.5768176200981194, + "v": [ + 0.009657934584899294, + 0.02418870866616249 + ], + "ease": [ + 0.10285717371228631, + 0.44298137016142797, + 0.58, + 1 + ] + }, + { + "t": 1.7545755610844578, + "v": [ + 0.005569779366063913, + -0.08650009457623184 + ], + "ease": [ + 0.11393164333435832, + 0.20842906107559217, + 0.58, + 1 + ] + }, + { + "t": 1.9557554786340765, + "v": [ + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "rotation": [ + { + "t": 0, + "v": [ + 0, + 0, + 0 + ], + "ease": [ + 0.7521899922111783, + 0, + 0.29680419697434735, + 1.2284132127170915 + ] + }, + { + "t": 0.3798103165938864, + "v": [ + -31.25365067481446, + -5.533499139447874, + 13.746823539571478 + ], + "ease": [ + 0.6650959278914218, + 0.08187947429053509, + 0.027403916739799694, + 1.175374728587553 + ] + }, + { + "t": 0.9426716022659842, + "v": [ + -30.6162399609899, + 14.144576441037861, + -12.73974221895198 + ], + "ease": [ + 0.8992655949641883, + 0.06226468791954258, + 0.6897429932929974, + 1.0606800225733635 + ] + }, + { + "t": 1.3972576410224693, + "v": [ + 19.356472882732792, + 0.12303000388944017, + -1.6697872707546177 + ], + "ease": [ + 0.5369766709678977, + 0.09639530999237222, + 0.4242841447981972, + 0.8655803895199093 + ] + }, + { + "t": 1.5873362914457065, + "v": [ + -4.965245810760847, + -1.410803032494741, + -0.08557034006007683 + ], + "ease": [ + 0.10285717371228631, + 0.44298137016142797, + 0.58, + 1 + ] + }, + { + "t": 1.7574889536920633, + "v": [ + 12.945220298267941, + -0.9549298574076148, + -1.3541756721371099 + ], + "ease": [ + 0.11393164333435832, + 0.20842906107559217, + 0.58, + 1 + ] + }, + { + "t": 1.9557554786340765, + "v": [ + 0, + 0, + 0 + ], + "ease": [ + 0.7521899922111783, + 0, + 0.29680419697434735, + 1.2284132127170915 + ] + } + ] + } + }, + { + "name": "Idle_A", + "duration": 6, + "body": "sphere", + "squashAnchor": 0, + "workArea": null, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "position": [ + { + "t": 0.4874467656537753, + "v": [ + 0, + 0 + ], + "ease": [ + 0.39919726822734797, + -0.1380913825536936, + 0.15951040179591214, + 1 + ] + }, + { + "t": 1.3832000000000173, + "v": [ + -0.14647919001828796, + 0.027266090496135924 + ], + "ease": [ + 0.5825263567433967, + 0.31017058172003364, + 0.04617913732408452, + 1.3610608114532912 + ] + }, + { + "t": 1.77965296961326, + "v": [ + -0.14647919001828796, + 0.027266090496135924 + ], + "ease": [ + 0.22005965712189499, + 0.31017058172003364, + 0.04617913732408452, + 0.9652306378531971 + ] + }, + { + "t": 2.75, + "v": [ + -0.0745899946905787, + -0.16596550351011732 + ], + "ease": [ + 0.7924378812353865, + 0.08203268815009183, + 0.20257664629337524, + 0.5708768022766131 + ] + }, + { + "t": 2.9261481353591154, + "v": [ + -0.0745899946905787, + -0.16596550351011732 + ], + "ease": [ + 0.9024115240536277, + 0.09528443113772456, + 0.31187154968454267, + 0.9286115269461079 + ] + }, + { + "t": 4.0795674814720835, + "v": [ + 0, + 0 + ], + "ease": [ + 0.8772841365461845, + 0.09396460316883949, + 0, + 0.9378491736838274 + ] + }, + { + "t": 5.754212106241882, + "v": [ + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "rotation": [ + { + "t": 0.4874467656537753, + "v": [ + 0, + 0, + 0 + ], + "ease": [ + 0.39919726822734797, + -0.1380913825536936, + 0.15951040179591214, + 1 + ] + }, + { + "t": 1.3832000000000173, + "v": [ + -16.083597442672094, + -32.69316873461356, + -11.038560548848148 + ], + "ease": [ + 0.5825263567433967, + 0.31017058172003364, + 0.04617913732408452, + 1.3610608114532912 + ] + }, + { + "t": 1.77965296961326, + "v": [ + -13.471262391203387, + -31.664036845872012, + -11.622988481798327 + ], + "ease": [ + 0.22005965712189499, + 0.31017058172003364, + 0.04617913732408452, + 0.9652306378531971 + ] + }, + { + "t": 2.75, + "v": [ + 10.37381753258554, + 13.583422586232203, + 10.666197221172675 + ], + "ease": [ + 0.7924378812353865, + 0.08203268815009183, + 0.20257664629337524, + 0.5708768022766131 + ] + }, + { + "t": 2.9261481353591154, + "v": [ + 22.819835891847944, + 5.292311893891789, + 5.163005094237094 + ], + "ease": [ + 1, + -0.5, + 0.4051656151419559, + 0.8872272583365959 + ] + }, + { + "t": 4.0795674814720835, + "v": [ + -24.875244874578584, + 31.812786832669843, + 15.07444269105954 + ], + "ease": [ + 0.8772841365461845, + 0.09396460316883949, + 0, + 0.9378491736838274 + ] + }, + { + "t": 5.754212106241882, + "v": [ + 0, + 360, + 0 + ], + "ease": [ + 0.9122920380277829, + 0.038550560528246756, + 0.4251612081579069, + 0.8172019143128422 + ] + } + ], + "expression": [ + { + "t": 1.1628970994475138, + "v": "blink", + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 4.00439785911593, + "v": "blink", + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 5.643314779005485, + "v": "neutral", + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ] + } + }, + { + "name": "Idle_B", + "duration": 4, + "body": "sphere", + "squashAnchor": 0, + "workArea": null, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "position": [ + { + "t": 1.6054821209465213, + "v": [ + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "rotation": [ + { + "t": 0.9010370289220491, + "v": [ + 0, + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.28927730966925536, + 0.9624241226685105 + ] + }, + { + "t": 1.6163686240140267, + "v": [ + 0.12032076750937498, + -8.658512604326788, + -2.5426418939710658 + ], + "ease": [ + 0.8747812559101849, + -0.03625853912624016, + 0.25467036702156304, + 0.8980229024542974 + ] + }, + { + "t": 2.4902049517966525, + "v": [ + 0.9659548057072269, + 5.658900089349377, + 1.7129240357574211 + ], + "ease": [ + 0.6027205111294465, + -0.23084976154500042, + 0.2902495275740431, + 0.9815467952425966 + ] + }, + { + "t": 3.45704978089402, + "v": [ + 0, + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "scale": [ + { + "t": 0, + "v": [ + 0.6 + ], + "ease": [ + 0.6180446538332104, + 0.0012432525890504465, + 0.1839130507609582, + 0.9358696746423821 + ] + }, + { + "t": 1.6054821209465213, + "v": [ + 0.64 + ], + "ease": [ + 0.8658706538170823, + -0.07995460153625837, + 0.29979142380351903, + 1.0195121615474219 + ] + }, + { + "t": 3.841942375109553, + "v": [ + 0.6 + ], + "ease": [ + 0.6180446538332104, + 0.0012432525890504465, + 0.1839130507609582, + 0.9405530305724485 + ] + } + ], + "expression": [ + { + "t": 1.4811034202342213, + "v": "blink", + "ease": [ + 0.4, + 0.22, + 0.28, + 1.34 + ] + }, + { + "t": 3.45704978089402, + "v": "blink", + "ease": [ + 0.4, + 0.22, + 0.28, + 1.34 + ] + } + ] + } + }, + { + "name": "Idle_D", + "duration": 6, + "body": "sphere", + "squashAnchor": 0, + "workArea": null, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "rotation": [ + { + "t": 0, + "v": [ + -15.896271043294888, + 33.29315635042974, + 25.38480127460161 + ], + "ease": [ + 0.6511065418455951, + 0, + 0.3268385117846959, + 1 + ] + }, + { + "t": 0.5867355510752692, + "v": [ + -34.32339609164593, + 16.86196736106905, + 15.137354734738835 + ], + "ease": [ + 0.5996715199956071, + 0, + 0.24671113951908907, + 1 + ] + }, + { + "t": 1.0783308131720428, + "v": [ + -34.32339609164593, + 16.86196736106905, + 15.137354734738835 + ], + "ease": [ + 0.5996715199956071, + 0, + 0.10657397408207336, + 1 + ] + }, + { + "t": 1.962638608870968, + "v": [ + -6.1682538522357975, + 3.098853229789992, + 14.349419848128031 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 2.671748991935484, + "v": [ + -6.1682538522357975, + 3.098853229789992, + 14.349419848128031 + ], + "ease": [ + 0.42, + 0, + 0.2029056425485961, + 1 + ] + }, + { + "t": 3.914377520161291, + "v": [ + 7.572938650100637, + 40.386943374478015, + 25.47389006919084 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 4.785706485215049, + "v": [ + 7.572938650100637, + 40.386943374478015, + 25.47389006919084 + ], + "ease": [ + 0.6315554130669547, + 0, + 0.21641299946004333, + 1 + ] + }, + { + "t": 5.632732694892469, + "v": [ + -15.896271043294888, + 33.29315635042974, + 25.38480127460161 + ], + "ease": [ + 0.6511065418455951, + 0, + 0.3268385117846959, + 1 + ] + } + ], + "expression": [ + { + "t": 0.5929099462365591, + "v": "blink", + "ease": [ + 0.4, + 0.22, + 0.28, + 1.34 + ] + }, + { + "t": 2.0691383064517033, + "v": "blink", + "ease": [ + 0.4, + 0.22, + 0.28, + 1.34 + ] + }, + { + "t": 2.3722908266129035, + "v": "blink", + "ease": [ + 0.4, + 0.22, + 0.28, + 1.34 + ] + }, + { + "t": 4.247479838709677, + "v": "blink", + "ease": [ + 0.4, + 0.22, + 0.28, + 1.34 + ] + } + ] + } + }, + { + "name": "Logo_A", + "duration": 4, + "body": "sphere", + "squashAnchor": 0, + "workArea": { + "start": 0, + "end": 2.0002578769692425 + }, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "rotation": [ + { + "t": 0, + "v": [ + -15.896271043294888, + 33.29315635042974, + 25.38480127460161 + ], + "ease": [ + 0.940308570092613, + 0, + 0.8322175812019608, + 0.42841310531523513 + ] + }, + { + "t": 0.9325431465191484, + "v": [ + -15.896271043294888, + 290.3612956381275, + 25.38480127460161 + ], + "ease": [ + 0.08370250411471428, + 0.7821860109261747, + 0.1762354651162792, + 1 + ] + }, + { + "t": 1.9567938859714928, + "v": [ + -15.896271043294888, + 393.29315635042974, + 25.38480127460161 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ] + } + }, + { + "name": "Logo_B", + "duration": 4, + "body": "sphere", + "squashAnchor": 0, + "workArea": { + "start": 0, + "end": 3.1220695798949736 + }, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "rotation": [ + { + "t": 0, + "v": [ + -15.896271043294888, + 33.29315635042974, + 25.38480127460161 + ], + "ease": [ + 0.77323790487339, + 0, + 0.19818347223390587, + 0.9476806070764106 + ] + }, + { + "t": 0.7185100111661579, + "v": [ + -11.287778634690255, + -33.00231519019303, + -21.062449423926747 + ], + "ease": [ + 0.2717748154604947, + -0.2636988609351958, + 0.6005198984279181, + 0.023491972600300434 + ] + }, + { + "t": 1.141527569392348, + "v": [ + -11.399396607755296, + -31.39663474186699, + -19.937494120163255 + ], + "ease": [ + 0.9137669521901326, + 0.04016940837145343, + 0.8322175812019608, + 0.42841310531523513 + ] + }, + { + "t": 2.074070715911496, + "v": [ + -15.896271043294888, + 290.3612956381275, + 25.38480127460161 + ], + "ease": [ + 0.08370250411471428, + 0.7821860109261747, + 0.1762354651162792, + 1 + ] + }, + { + "t": 3.0983214553638407, + "v": [ + -15.896271043294888, + 393.29315635042974, + 25.38480127460161 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ] + } + }, + { + "name": "Marketing_A", + "duration": 4, + "body": "sphere", + "squashAnchor": 0, + "workArea": null, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "position": [ + { + "t": 0.6167064755838638, + "v": [ + 0, + 0 + ], + "ease": [ + 0.5771393784786643, + 0, + 0.41654104823747684, + 1 + ] + }, + { + "t": 1.4190773177636233, + "v": [ + -0.16387514369529332, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 2.2684226822363764, + "v": [ + -0.16387514369529332, + 0.05904395257747427 + ], + "ease": [ + 0.580719503710575, + 0, + 0, + 1 + ] + }, + { + "t": 3.1610281808035716, + "v": [ + 0, + 0 + ], + "ease": [ + 0.5771393784786643, + 0, + 0.41654104823747684, + 1 + ] + } + ], + "rotation": [ + { + "t": 0, + "v": [ + -15.896271043294888, + 33.29315635042974, + 25.38480127460161 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.6167064755838638, + "v": [ + -15.896271043294888, + 33.29315635042974, + 25.38480127460161 + ], + "ease": [ + 0.8625910776088882, + -0.041613616273912, + 0.25952275632662025, + 1.049784301761229 + ] + }, + { + "t": 1.4190773177636233, + "v": [ + -2.97589945531819, + -26.234478989223906, + -3.2544499064295738 + ], + "ease": [ + 0.7818125791576046, + 0.15346315752484826, + 0.32687664998404986, + 1.1173618829561318 + ] + }, + { + "t": 2.2684226822363764, + "v": [ + -31.58476211869586, + -3.710798107118315, + 24.071416454447956 + ], + "ease": [ + 0.36229770668640776, + -0.17418027527991467, + 0.2875659277885649, + 1.2452842602722636 + ] + }, + { + "t": 2.7962446921443735, + "v": [ + -27.874087487241205, + 0.739249290532289, + 5.007074162025872 + ], + "ease": [ + 0.42, + 0.044881803684311196, + 0.58, + 1 + ] + }, + { + "t": 3.2725893489030398, + "v": [ + -19.255583925594408, + -17.414119894980235, + -7.023063437300964 + ], + "ease": [ + 0.49415439617951884, + -0.031041466813226757, + 0.2907565775851124, + 1 + ] + }, + { + "t": 3.8435756138392856, + "v": [ + -19.353875077555468, + 20.03044512007725, + 19.013275491556097 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 3.9805733816964284, + "v": [ + -15.896271043294888, + 33.29315635042974, + 25.38480127460161 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "expression": [ + { + "t": 0.05960518973214285, + "v": "neutral", + "ease": [ + 0.7416814271255061, + 0.03220016891891886, + 0.26459703947368424, + 1.0216427364864864 + ] + }, + { + "t": 2.2434315286624202, + "v": "surprised", + "ease": [ + 0.7416814271255061, + 0.03220016891891886, + 0.26459703947368424, + 1.0216427364864864 + ] + } + ] + } + }, + { + "name": "Marketing_B", + "duration": 4, + "body": "sphere", + "squashAnchor": 0, + "workArea": null, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "rotation": [ + { + "t": 0, + "v": [ + -15.896271043294888, + 33.29315635042974, + 25.38480127460161 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.6167064755838638, + "v": [ + -15.896271043294888, + 33.29315635042974, + 25.38480127460161 + ], + "ease": [ + 0.8625910776088882, + -0.041613616273912, + 0.25952275632662025, + 1.049784301761229 + ] + }, + { + "t": 1.4190773177636233, + "v": [ + -2.97589945531819, + -26.234478989223906, + -3.2544499064295738 + ], + "ease": [ + 0.7818125791576046, + 0.15346315752484826, + 0.32687664998404986, + 1.1173618829561318 + ] + }, + { + "t": 2.2684226822363764, + "v": [ + 20.16565207199379, + -20.644446669166673, + 22.47273384376682 + ], + "ease": [ + 0.36229770668640776, + -0.17418027527991467, + 0.2875659277885649, + 1.2452842602722636 + ] + }, + { + "t": 3.2725893489030398, + "v": [ + -20.98869306129836, + -21.248460672675545, + -6.20573622722674 + ], + "ease": [ + 0.49415439617951884, + -0.031041466813226757, + 0.2907565775851124, + 1 + ] + }, + { + "t": 4, + "v": [ + -23.757982259034762, + 24.83627384870201, + 18.82083960330674 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "expression": [ + { + "t": 0, + "v": "neutral", + "ease": [ + 0.7416814271255061, + 0.03220016891891886, + 0.26459703947368424, + 1.0216427364864864 + ] + }, + { + "t": 1.4471868365180467, + "v": "calm", + "ease": [ + 0.7416814271255061, + 0.03220016891891886, + 0.26459703947368424, + 1.0216427364864864 + ] + } + ] + } + }, + { + "name": "Marketing_C", + "duration": 4, + "body": "sphere", + "squashAnchor": 0, + "workArea": null, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "rotation": [ + { + "t": 0, + "v": [ + -15.896271043294888, + 33.29315635042974, + 25.38480127460161 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.6167064755838638, + "v": [ + -15.896271043294888, + 33.29315635042974, + 25.38480127460161 + ], + "ease": [ + 0.8625910776088882, + -0.041613616273912, + 0.25952275632662025, + 1.049784301761229 + ] + }, + { + "t": 1.4190773177636233, + "v": [ + -2.97589945531819, + -26.234478989223906, + -3.2544499064295738 + ], + "ease": [ + 0.7818125791576046, + 0.15346315752484826, + 0.32687664998404986, + 1.1173618829561318 + ] + }, + { + "t": 2.2684226822363764, + "v": [ + 20.16565207199379, + -20.644446669166673, + 22.47273384376682 + ], + "ease": [ + 0.36229770668640776, + -0.17418027527991467, + 0.2875659277885649, + 1.2452842602722636 + ] + }, + { + "t": 3.2725893489030398, + "v": [ + -20.98869306129836, + -21.248460672675545, + -6.20573622722674 + ], + "ease": [ + 0.49415439617951884, + -0.031041466813226757, + 0.2907565775851124, + 1 + ] + }, + { + "t": 4, + "v": [ + -23.757982259034762, + 24.83627384870201, + 18.82083960330674 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "expression": [ + { + "t": 0, + "v": "neutral", + "ease": [ + 0.7416814271255061, + 0.03220016891891886, + 0.26459703947368424, + 1.0216427364864864 + ] + }, + { + "t": 1.4471868365180467, + "v": "calm", + "ease": [ + 0.7416814271255061, + 0.03220016891891886, + 0.26459703947368424, + 1.0216427364864864 + ] + } + ] + } + }, + { + "name": "Marketing_D", + "duration": 4, + "body": "sphere", + "squashAnchor": -1, + "workArea": null, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "expression": [ + { + "t": 0.05498053786270347, + "v": "neutral", + "ease": [ + 0.7416814271255061, + 0.03220016891891886, + 0.26459703947368424, + 1.0216427364864864 + ] + }, + { + "t": 0.7522248761500355, + "v": "happy", + "ease": [ + 0.7416814271255061, + 0.03220016891891886, + 0.26459703947368424, + 1.0216427364864864 + ] + }, + { + "t": 3.188380537862626, + "v": "neutral", + "ease": [ + 0.7416814271255061, + 0.03220016891891886, + 0.26459703947368424, + 1.0216427364864864 + ] + } + ], + "squash": [ + { + "t": 0.6800000000000002, + "v": [ + 1, + 1 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.9199999999999999, + "v": [ + 1.12, + 0.88 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 1.1400000000000001, + "v": [ + 0.93, + 1.09 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 1.3600000000000003, + "v": [ + 1.04, + 0.97 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 1.6, + "v": [ + 1, + 1 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "rotation": [ + { + "t": 0, + "v": [ + -6.345525006325851, + 19.726304895345887, + 7.225894380877916 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.68, + "v": [ + -6.345525006325851, + 19.726304895345887, + 7.225894380877916 + ], + "ease": [ + 0.6287715517241379, + 0, + 0.1652870297805643, + 1 + ] + }, + { + "t": 1.80886178343963, + "v": [ + 7.946317173704124, + -27.372379409841862, + -13.611633161590936 + ], + "ease": [ + 0.5888217507250163, + 0, + 0, + 1 + ] + }, + { + "t": 3.406449044585987, + "v": [ + 0, + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 3.887429228591649, + "v": [ + -6.345525006325851, + 19.726304895345887, + 7.225894380877916 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ] + } + }, + { + "name": "Rotation_A", + "duration": 6, + "body": "cloud", + "squashAnchor": 0, + "workArea": null, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "rotation": [ + { + "t": 0, + "v": [ + 2.3120950657104795, + -34.698071961330996, + 6.287220573778937 + ], + "ease": [ + 0.001, + 0.001, + 0.999, + 0.999 + ] + }, + { + "t": 3.0420576880625374, + "v": [ + -15.896271043294888, + 393.29315635042974, + 25.38480127460161 + ], + "ease": [ + 0.001, + 0.001, + 0.999, + 0.999 + ] + }, + { + "t": 5.685518927730484, + "v": [ + -26.2, + 719.4, + 25.38480127460161 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ] + } + }, + { + "name": "Rotation_B", + "duration": 10, + "body": "cloud", + "squashAnchor": 0, + "workArea": null, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "rotation": [ + { + "t": 0, + "v": [ + -5.86170199829258, + -66.48579621643268, + 11.013855401509232 + ], + "ease": [ + 0.001, + 0.001, + 0.999, + 0.999 + ] + }, + { + "t": 10, + "v": [ + -5.86170199829258, + 293.5142037835673, + 11.013855401509232 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ] + } + }, + { + "name": "Rotation_C", + "duration": 4, + "body": "cloud", + "squashAnchor": 0, + "workArea": null, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "position": [ + { + "t": 0, + "v": [ + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "rotation": [ + { + "t": 0, + "v": [ + -35, + -28.5, + 8.9 + ], + "ease": [ + 0.001, + 0.001, + 0.999, + 0.999 + ] + }, + { + "t": 3.954889112903226, + "v": [ + 30.3, + -25.03032384031022, + 25.838583124958074 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "expression": [ + { + "t": 2.02137936827957, + "v": "blink", + "ease": [ + 0.4, + 0.22, + 0.28, + 1.34 + ] + } + ] + } + }, + { + "name": "Streaming_A", + "duration": 2.6, + "body": "sphere", + "squashAnchor": 0, + "workArea": { + "start": 0, + "end": 2.593141413751879 + }, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "rotation": [ + { + "t": 0, + "v": [ + -17.330877831673277, + 2.263279637703984, + 15.443629275983977 + ], + "ease": [ + 0.7300325174456456, + -0.12405425024558042, + 0.40585908939755444, + 1.0050376870076922 + ] + }, + { + "t": 0.3458182816671912, + "v": [ + 9.638628832336948, + 2.025895269795649, + -1.2527648183630171 + ], + "ease": [ + 0.4224707625763918, + 0.0022972310598335997, + 0.58, + 0.9927354054381194 + ] + }, + { + "t": 0.5813687143348092, + "v": [ + -15.366003917619995, + -2.619836674995623, + -2.16549892253304 + ], + "ease": [ + 0.7322498965237162, + -0.011667049678267111, + 0, + 0.9141484219009502 + ] + }, + { + "t": 0.8343818658890081, + "v": [ + 4.338039365692767, + 0.49348825557993187, + -3.7087410402723067 + ], + "ease": [ + 0.1580317356155577, + -0.07611268668891613, + 0.32045494511798944, + 1.0963542960860653 + ] + }, + { + "t": 1.2183655201918815, + "v": [ + -0.2793440526930908, + 0, + 0 + ], + "ease": [ + 0.7202644919359068, + 0, + 0.414580660247694, + 0.9498001208025284 + ] + }, + { + "t": 1.6185917357603328, + "v": [ + -5.399115822872089, + 14.530502605079048, + 3.1285787766973128 + ], + "ease": [ + 0.603730806420981, + -0.0304892240026259, + 0.2700959962528573, + 0.9305186654092706 + ] + }, + { + "t": 2.0602042690217752, + "v": [ + -2.088336039091766, + -10.814208972017363, + -20.009405175503947 + ], + "ease": [ + 0.48373539910046165, + -0.09542268080702558, + 0.26548699703136136, + 1 + ] + }, + { + "t": 2.593141413751879, + "v": [ + -17.330877831673277, + 2.263279637703984, + 15.443629275983977 + ], + "ease": [ + 0.7300325174456456, + -0.12405425024558042, + 0.40585908939755444, + 1.0050376870076922 + ] + } + ], + "position": [ + { + "t": 0.008051106963364502, + "v": [ + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.34800758255567965, + "v": [ + 0, + -0.09893530004977505 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.5873549334090005, + "v": [ + 0, + 0.04654750224419043 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.8343818658890081, + "v": [ + 0, + 0.004683687521738383 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 1.2183655201918815, + "v": [ + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.27743996350027533, + 0.9367983894536062 + ] + }, + { + "t": 1.6185917357603328, + "v": [ + 0.12655004394233813, + 0 + ], + "ease": [ + 0.603730806420981, + -0.0304892240026259, + 0.2700959962528573, + 0.9282552090918559 + ] + }, + { + "t": 2.0602042690217752, + "v": [ + -0.1362091399555723, + 0 + ], + "ease": [ + 0.48373539910046165, + -0.09542268080702558, + 0.26548699703136136, + 1 + ] + }, + { + "t": 2.593141413751879, + "v": [ + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ] + } + }, + { + "name": "Streaming_B", + "duration": 2.6, + "body": "sphere", + "squashAnchor": 0, + "workArea": { + "start": 0, + "end": 2.593141413751879 + }, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "expression": [ + { + "t": 0.8343818658890081, + "v": "surprised", + "ease": [ + 0.8458217358299596, + 0.40487753378378377, + 0.13769926619433198, + 0.9154349662162162 + ], + "cut": true + }, + { + "t": 2.1679160926807017, + "v": "neutral", + "ease": [ + 0.8458217358299596, + 0.40487753378378377, + 0.13769926619433198, + 0.9154349662162162 + ], + "cut": true + } + ], + "rotation": [ + { + "t": 0, + "v": [ + -15.896271043294888, + 33.29315635042974, + 25.38480127460161 + ], + "ease": [ + 0.7300325174456456, + -0.12405425024558042, + 0.40585908939755444, + 1.0050376870076922 + ] + }, + { + "t": 0.3458182816671912, + "v": [ + 9.638628832336948, + 2.025895269795649, + -1.2527648183630171 + ], + "ease": [ + 0.4224707625763918, + 0.0022972310598335997, + 0.58, + 0.9927354054381194 + ] + }, + { + "t": 0.8343818658890081, + "v": [ + -13.158704916519811, + -16.90797681471658, + -2.914418472822627 + ], + "ease": [ + 0.1580317356155577, + -0.07611268668891613, + 0.32045494511798944, + 1.0963542960860653 + ] + }, + { + "t": 1.2183655201918815, + "v": [ + 3.3337098816649693, + 21.543227556484112, + 4.461698658684633 + ], + "ease": [ + 0.7202644919359068, + 0, + 0.414580660247694, + 0.9498001208025284 + ] + }, + { + "t": 1.6185917357603328, + "v": [ + -5.399115822872089, + 14.530502605079048, + 3.1285787766973128 + ], + "ease": [ + 0.603730806420981, + -0.0304892240026259, + 0.2700959962528573, + 0.9305186654092706 + ] + }, + { + "t": 2.0602042690217752, + "v": [ + -2.088336039091766, + -10.814208972017363, + -20.009405175503947 + ], + "ease": [ + 0.48373539910046165, + -0.09542268080702558, + 0.26548699703136136, + 1 + ] + }, + { + "t": 2.593141413751879, + "v": [ + -15.896271043294888, + 33.29315635042974, + 25.38480127460161 + ], + "ease": [ + 0.7300325174456456, + -0.12405425024558042, + 0.40585908939755444, + 1.0050376870076922 + ] + } + ], + "position": [ + { + "t": 0.008051106963364502, + "v": [ + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.34800758255567965, + "v": [ + 0, + -0.09893530004977505 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.8343818658890081, + "v": [ + 0, + 0.004683687521738383 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 1.36568364300849, + "v": [ + 0.12655004394233813, + 0 + ], + "ease": [ + 0.603730806420981, + -0.0304892240026259, + 0.2700959962528573, + 0.9282552090918559 + ] + }, + { + "t": 2.0602042690217752, + "v": [ + -0.1362091399555723, + 0 + ], + "ease": [ + 0.48373539910046165, + -0.09542268080702558, + 0.26548699703136136, + 1 + ] + }, + { + "t": 2.593141413751879, + "v": [ + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ] + } + }, + { + "name": "Streaming_C", + "duration": 2.4, + "body": "sphere", + "squashAnchor": 0, + "workArea": null, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "expression": [ + { + "t": 0.0930430477108114, + "v": "squinted", + "ease": [ + 0.9601942054655871, + -0.052470439189189255, + 0.20804339574898786, + 0.9135346283783784 + ] + } + ], + "rotation": [ + { + "t": 0.007536501544973762, + "v": [ + -30.114678799869385, + -0.3481515921755908, + 10.657733527966457 + ], + "ease": [ + 0.8069695446331039, + 0.06068366162676136, + 0.22176992691461772, + 1.0529567094556063 + ] + }, + { + "t": 0.6967011702649526, + "v": [ + -25.983934007381276, + 7.767555485689494, + -17.97884031792412 + ], + "ease": [ + 0.6337438554404256, + 0.03915919163464518, + 0.24060607252134747, + 0.9552013782917067 + ] + }, + { + "t": 1.3, + "v": [ + -27.316297934452344, + -1.3907007432867593, + 11.301625700367927 + ], + "ease": [ + 0.8069695446331039, + 0.06068366162676136, + 0.05967881944444445, + 0.9976179673321233 + ] + }, + { + "t": 2.3830582002457, + "v": [ + -30.114678799869385, + 359.6518484078244, + 10.657733527966457 + ], + "ease": [ + 0.545887017284529, + -0.16488185903681707, + 0.58, + 1 + ] + } + ], + "position": [ + { + "t": 0.007536501544973762, + "v": [ + 0, + 0 + ], + "ease": [ + 0.8069695446331039, + 0.06068366162676136, + 0.22176992691461772, + 1.0529567094556063 + ] + }, + { + "t": 0.6967011702649526, + "v": [ + 0.16304040871678732, + 0.1060830475460072 + ], + "ease": [ + 0.6337438554404256, + 0.03915919163464518, + 0.24060607252134747, + 0.9552013782917067 + ] + }, + { + "t": 1.3, + "v": [ + -0.16109273893088566, + 0.1112767252235085 + ], + "ease": [ + 0.8069695446331039, + 0.06068366162676136, + 0.05967881944444445, + 0.9976179673321233 + ] + }, + { + "t": 2.3830582002457, + "v": [ + 0, + 0 + ], + "ease": [ + 0.7622975213794354, + -0.39274360300976613, + 0.22176992691461772, + 1.0529567094556063 + ] + } + ] + } + }, + { + "name": "Working_A", + "duration": 8, + "body": "sphere", + "squashAnchor": 0, + "workArea": null, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "position": [ + { + "t": 0.4874467656537753, + "v": [ + 0, + 0 + ], + "ease": [ + 0.39919726822734797, + -0.1380913825536936, + 0.15951040179591214, + 1 + ] + }, + { + "t": 1.3832000000000173, + "v": [ + -0.14647919001828796, + 0.027266090496135924 + ], + "ease": [ + 0.5825263567433967, + 0.31017058172003364, + 0.04617913732408452, + 1.3610608114532912 + ] + }, + { + "t": 1.77965296961326, + "v": [ + -0.14647919001828796, + 0.027266090496135924 + ], + "ease": [ + 0.22005965712189499, + 0.31017058172003364, + 0.04617913732408452, + 0.9652306378531971 + ] + }, + { + "t": 2.5961328343722427, + "v": [ + 0.06209379465135341, + 0.0363636826260471 + ], + "ease": [ + 0.6823010997778416, + 0.03975119923438924, + 0.58, + 0.7462988396745895 + ] + }, + { + "t": 3.066377943544433, + "v": [ + -0.016487420591570615, + 0.0804209848791122 + ], + "ease": [ + 0.8034080182827295, + -0.08276325518488785, + 0.6510110561346553, + 0.8128258749805829 + ] + }, + { + "t": 3.426085780646072, + "v": [ + -0.008916270094194876, + -0.2135819760518346 + ], + "ease": [ + 0.1744021985074266, + -0.5, + 0.8865519014643501, + -0.2214168917492133 + ] + }, + { + "t": 4.00439785911593, + "v": [ + -0.0052248049102180776, + -0.12515593913381462 + ], + "ease": [ + 0.023812816055625345, + 0.31341394515796384, + 0.0448947139696587, + 1.0658309377957906 + ] + }, + { + "t": 5.005029942257856, + "v": [ + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 6.298778790534628, + "v": [ + 0.03813914798218992, + -0.019258402700427682 + ], + "ease": [ + 0.576933472628317, + 0.007827540903749368, + 0.12610215878412523, + 0.9598864308897559 + ] + }, + { + "t": 6.952426906222622, + "v": [ + -0.25, + -0.004137934943726919 + ], + "ease": [ + 0.8326089284460128, + -0.06392110845094923, + 0.3654460022570882, + 1.0352901568389925 + ] + }, + { + "t": 7.900964066608238, + "v": [ + 0, + 0 + ], + "ease": [ + 0.39919726822734797, + -0.1380913825536936, + 0.15951040179591214, + 1 + ] + } + ], + "rotation": [ + { + "t": 0.4874467656537753, + "v": [ + 0, + 0, + 0 + ], + "ease": [ + 0.39919726822734797, + -0.1380913825536936, + 0.15951040179591214, + 1 + ] + }, + { + "t": 1.3832000000000173, + "v": [ + -11.013174031248813, + -20.059833580125414, + -12.896783267756348 + ], + "ease": [ + 0.5825263567433967, + 0.31017058172003364, + 0.04617913732408452, + 1.3610608114532912 + ] + }, + { + "t": 1.77965296961326, + "v": [ + -17.48975478197329, + -18.703976724880516, + -12.678205332257816 + ], + "ease": [ + 0.22005965712189499, + 0.31017058172003364, + 0.04617913732408452, + 0.9652306378531971 + ] + }, + { + "t": 2.5961328343722427, + "v": [ + -14.554913391638532, + 17.164836260093473, + 9.376528927180779 + ], + "ease": [ + 0.6823010997778416, + 0.03975119923438924, + 0.58, + 0.9240336563778733 + ] + }, + { + "t": 3.1796016343940767, + "v": [ + -24.586251970533304, + 0.14133926125606477, + -0.7477216271856462 + ], + "ease": [ + 0.8034080182827295, + -0.08276325518488785, + 0.9143306014396848, + 0.4851034680582119 + ] + }, + { + "t": 3.4679142027965795, + "v": [ + -1.0943281912285556, + -1.1346215368163393, + -0.7116952620126241 + ], + "ease": [ + 0.12040927423864656, + -0.18872517040581954, + 0.9481776569383256, + -0.20917925086224787 + ] + }, + { + "t": 4.001861402631413, + "v": [ + -147.11069531984157, + -0.6757806869448113, + -0.4238857605398247 + ], + "ease": [ + 0.03131913716814155, + 0.8644907690193963, + 0.0448947139696587, + 1.0658309377957906 + ] + }, + { + "t": 5.005029942257856, + "v": [ + -360, + 0, + 0 + ], + "ease": [ + 0.7970933306500759, + -0.5, + 0.24118105710043447, + 0.7361553789450831 + ] + }, + { + "t": 6.298778790534628, + "v": [ + -364.89323527138873, + 18.626179650675688, + -0.2598233988596897 + ], + "ease": [ + 0.576933472628317, + 0.007827540903749368, + 0.12610215878412523, + 0.9742744867440337 + ] + }, + { + "t": 6.952426906222622, + "v": [ + -366.80423922162356, + -23.365336474310464, + -3.339162411443921 + ], + "ease": [ + 0.8326089284460128, + -0.06392110845094923, + 0.3654460022570882, + 1.0352901568389925 + ] + }, + { + "t": 7.900964066608238, + "v": [ + -360, + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "expression": [ + { + "t": 1.1672000000000002, + "v": "blink", + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 1.77965296961326, + "v": "surprised", + "ease": [ + 0.7394673582995951, + 0.10409628378378377, + 0.6676682692307693, + 1.5 + ] + }, + { + "t": 5.643314779005485, + "v": "neutral", + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 6.298778790534628, + "v": "strange", + "ease": [ + 0.9601942054655871, + -0.052470439189189255, + 0.20804339574898786, + 0.9135346283783784 + ] + }, + { + "t": 7.3115338882283, + "v": "neutral", + "ease": [ + 0.7416814271255061, + 0.03220016891891886, + 0.26459703947368424, + 1.0216427364864864 + ] + } + ] + } + }, + { + "name": "Working_B", + "duration": 4.8, + "body": "sphere", + "squashAnchor": 0, + "workArea": { + "start": 0, + "end": 4.8 + }, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "position": [ + { + "t": 0.17746730083234244, + "v": [ + 0, + 0 + ], + "ease": [ + 0.807066718478605, + 0.026672824415491948, + 0.05397305674842967, + 1.0519668384455731 + ] + }, + { + "t": 1.4141067024830507, + "v": [ + -0.1, + -0.000014911040730998862 + ], + "ease": [ + 0.7054694160272805, + 0.15290809768637542, + 0, + 0.9011176996605331 + ] + }, + { + "t": 2.75, + "v": [ + 0.1, + -0.0019533463357608513 + ], + "ease": [ + 0.8240202817829232, + -0.24792725943516045, + 0.2993925437700369, + 1 + ] + }, + { + "t": 4.7824019024970275, + "v": [ + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.07071724310528026, + 1.3032158407916754 + ] + } + ], + "rotation": [ + { + "t": 0.17746730083234244, + "v": [ + 0, + 0, + 0 + ], + "ease": [ + 0.807066718478605, + 0.026672824415491948, + 0.05397305674842967, + 1.0519668384455731 + ] + }, + { + "t": 1.4141067024830507, + "v": [ + 1.431495026310406, + -16.140319998213133, + -36.05175434586088 + ], + "ease": [ + 0.7054694160272805, + 0.15290809768637542, + 0, + 0.9507801322055007 + ] + }, + { + "t": 2.75, + "v": [ + -3.793664340840882, + 16.145026960161896, + 23.111555762366347 + ], + "ease": [ + 0.8145184258410576, + 0.05868417745499387, + 0.7187386211123404, + 0.8220737938312388 + ] + }, + { + "t": 3.300369915174868, + "v": [ + -0.658471135071176, + 32.094524718170625, + 23.00284198348812 + ], + "ease": [ + 0.9529378729987852, + -0.03412152368009878, + 0.7143170242443122, + 0.5254015949332431 + ] + }, + { + "t": 3.896330300821882, + "v": [ + -0.03018222484427137, + -273.02901001946327, + 5.757592653481982 + ], + "ease": [ + 0.11270319506726437, + 0.6690303636987784, + 0.23166630377840014, + 0.9738369897382892 + ] + }, + { + "t": 4.7824019024970275, + "v": [ + -0.02727988727869867, + -360, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "scale": [ + { + "t": 0.17779177760649373, + "v": [ + 0.6 + ], + "ease": [ + 0.807066718478605, + 0.026672824415491948, + 0.05397305674842967, + 0.9010023979880923 + ] + }, + { + "t": 1.4141067024830507, + "v": [ + 0.4215844279727266 + ], + "ease": [ + 0.758517646256336, + -0.08984960385218788, + 0.3074500979615823, + 0.9070651813931356 + ] + }, + { + "t": 2.25, + "v": [ + 0.5281520264776418 + ], + "ease": [ + 1, + 0.12529134929810246, + 0.3851632150743237, + 0.8426351606267645 + ] + }, + { + "t": 2.9883359098579825, + "v": [ + 0.6153393854797824 + ], + "ease": [ + 0.6185932743021068, + -0.26875053521000764, + 0.341686978852472, + 1 + ] + }, + { + "t": 4.608223713420763, + "v": [ + 0.6 + ], + "ease": [ + 0.807066718478605, + 0.026672824415491948, + 0.05397305674842967, + 1.0519668384455731 + ] + } + ], + "expression": [ + { + "t": 0.7466486120839366, + "v": "inspect", + "ease": [ + 0.7416814271255061, + 0.03220016891891886, + 0.26459703947368424, + 1.0216427364864864 + ] + }, + { + "t": 2.556108799048751, + "v": "surprised", + "ease": [ + 0.7416814271255061, + 0.03220016891891886, + 0.26459703947368424, + 1.0216427364864864 + ] + }, + { + "t": 3.7592152199762188, + "v": "neutral", + "ease": [ + 0.7416814271255061, + 0.03220016891891886, + 0.26459703947368424, + 1.0216427364864864 + ] + } + ] + } + }, + { + "name": "Working_C", + "duration": 5, + "body": "sphere", + "squashAnchor": 0, + "workArea": null, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "position": [ + { + "t": 0.17491812227074233, + "v": [ + -0.06835221071089878, + -0.000014911040730998862 + ], + "ease": [ + 0.48050979200063776, + 0.06136202638923106, + 0.999, + 1.5 + ] + }, + { + "t": 1.5108114197876916, + "v": [ + 0.032133292775302545, + -0.0019533463357608513 + ], + "ease": [ + 0.001, + 0.24090214653366723, + 0.58, + 1 + ] + }, + { + "t": 1.7971520856616507, + "v": [ + -0.06835221071089878, + -0.000014911040730998862 + ], + "ease": [ + 0.48050979200063776, + 0.06136202638923106, + 0.999, + 1.5 + ] + }, + { + "t": 3.1330453831785996, + "v": [ + 0.032133292775302545, + -0.0019533463357608513 + ], + "ease": [ + 0.001, + 0.24090214653366723, + 0.58, + 1 + ] + }, + { + "t": 3.4083292556973226, + "v": [ + -0.06835221071089878, + -0.000014911040730998862 + ], + "ease": [ + 0.48050979200063776, + 0.06136202638923106, + 0.999, + 1.5 + ] + }, + { + "t": 4.777367618612606, + "v": [ + 0.032133292775302545, + -0.0019533463357608513 + ], + "ease": [ + 0.001, + 0.24090214653366723, + 0.58, + 1 + ] + }, + { + "t": 5, + "v": [ + -0.06835221071089878, + -0.000014911040730998862 + ], + "ease": [ + 0.48050979200063776, + 0.06136202638923106, + 0.999, + 1.5 + ] + } + ], + "rotation": [ + { + "t": 0.17491812227074233, + "v": [ + 22.808320996747383, + -5.474402376762305, + -34.85465299871112 + ], + "ease": [ + 0.48050979200063776, + 0.06136202638923106, + 0.9994333239875214, + 1.5 + ] + }, + { + "t": 1.5108114197876916, + "v": [ + 25.82236401380847, + 8.163455755358939, + 36.34926880424543 + ], + "ease": [ + 0, + 0.24090214653366723, + 0.58, + 1 + ] + }, + { + "t": 1.7971520856616507, + "v": [ + 22.808320996747383, + -5.474402376762305, + -34.85465299871112 + ], + "ease": [ + 0.48050979200063776, + 0.06136202638923106, + 0.9994333239875214, + 1.5 + ] + }, + { + "t": 3.1330453831785996, + "v": [ + 25.82236401380847, + 8.163455755358939, + 36.34926880424543 + ], + "ease": [ + 0, + 0.24090214653366723, + 0.58, + 1 + ] + }, + { + "t": 3.4083292556973226, + "v": [ + 22.808320996747383, + -5.474402376762305, + -34.85465299871112 + ], + "ease": [ + 0.48050979200063776, + 0.06136202638923106, + 0.9994333239875214, + 1.5 + ] + }, + { + "t": 4.777367618612606, + "v": [ + 25.82236401380847, + 8.163455755358939, + 36.34926880424543 + ], + "ease": [ + 0, + 0.24090214653366723, + 0.58, + 1 + ] + }, + { + "t": 5, + "v": [ + 22.808320996747383, + -5.474402376762305, + -34.85465299871112 + ], + "ease": [ + 0.48050979200063776, + 0.06136202638923106, + 0.9994333239875214, + 1.5 + ] + } + ], + "expression": [ + { + "t": 0, + "v": "surprised", + "ease": [ + 0.7416814271255061, + 0.03220016891891886, + 0.26459703947368424, + 1.0216427364864864 + ] + } + ] + } + }, + { + "name": "Working_D", + "duration": 5, + "body": "sphere", + "squashAnchor": 0, + "workArea": { + "start": 0, + "end": 5 + }, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "position": [ + { + "t": 1.5264103096017296, + "v": [ + 0, + 0 + ], + "ease": [ + 0.8183794558884002, + 0, + 0.42063048773799955, + 0.8916191063669083 + ] + }, + { + "t": 2.2538209606986896, + "v": [ + 0.13302558581350077, + 0.08599672420051416 + ], + "ease": [ + 0.7164816671975539, + -0.07059803322016199, + 0.15856161687281733, + 1 + ] + }, + { + "t": 2.636972068926158, + "v": [ + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "rotation": [ + { + "t": 0, + "v": [ + -2.97589945531819, + -26.234478989223906, + -3.2544499064295738 + ], + "ease": [ + 0.7818125791576046, + 0.15346315752484826, + 0.32687664998404986, + 1.1173618829561318 + ] + }, + { + "t": 0.003820960698689868, + "v": [ + 2.4615600363954893, + -24.921015876700608, + 2.790543200988768 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.5222436429350663, + "v": [ + 20.16565207199379, + -20.644446669166673, + 22.47273384376682 + ], + "ease": [ + 0.36229770668640776, + -0.17418027527991467, + 0.2875659277885649, + 1.2452842602722636 + ] + }, + { + "t": 1.5264103096017296, + "v": [ + -20.98869306129836, + -21.248460672675545, + -6.20573622722674 + ], + "ease": [ + 0.49415439617951884, + -0.031041466813226757, + 0.2907565775851124, + 1 + ] + }, + { + "t": 2.2538209606986896, + "v": [ + -23.757982259034762, + 24.83627384870201, + 18.82083960330674 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 2.636972068926158, + "v": [ + 8.316132781421524, + -27.390741199393897, + -9.531735566337252 + ], + "ease": [ + 0.12247552635300568, + -0.002557518043474879, + 0.7043012111749869, + 0.5773041000961353 + ] + }, + { + "t": 3.494456667932666, + "v": [ + 6.3335276735657375, + -0.1762970079864843, + 0.6565665872756274 + ], + "ease": [ + 0.42, + 0.5038392468370619, + 0.58, + 1 + ] + }, + { + "t": 4.25382096069869, + "v": [ + 8.1703683326745, + 24.729657859977713, + 10.395084291173474 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 4.971120960698738, + "v": [ + 2.4615600363954893, + -24.921015876700608, + 2.790543200988768 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "expression": [ + { + "t": 0.003820960698689868, + "v": "calm", + "ease": [ + 0.7416814271255061, + 0.03220016891891886, + 0.26459703947368424, + 1.0216427364864864 + ] + } + ] + } + }, + { + "name": "Working_E", + "duration": 4, + "body": "sphere", + "squashAnchor": 0, + "workArea": null, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "position": [ + { + "t": 0.6167064755838638, + "v": [ + 0, + 0 + ], + "ease": [ + 0.5771393784786643, + 0, + 0.41654104823747684, + 1 + ] + }, + { + "t": 1.4190773177636233, + "v": [ + -0.16387514369529332, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 2.2684226822363764, + "v": [ + -0.16387514369529332, + 0.05904395257747427 + ], + "ease": [ + 0.580719503710575, + 0, + 0, + 1 + ] + }, + { + "t": 3.1610281808035716, + "v": [ + 0, + 0 + ], + "ease": [ + 0.5771393784786643, + 0, + 0.41654104823747684, + 1 + ] + } + ], + "rotation": [ + { + "t": 0, + "v": [ + -15.896271043294888, + 33.29315635042974, + 25.38480127460161 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 0.6167064755838638, + "v": [ + -15.896271043294888, + 33.29315635042974, + 25.38480127460161 + ], + "ease": [ + 0.8625910776088882, + -0.041613616273912, + 0.25952275632662025, + 1.049784301761229 + ] + }, + { + "t": 1.4190773177636233, + "v": [ + -2.97589945531819, + -26.234478989223906, + -3.2544499064295738 + ], + "ease": [ + 0.7818125791576046, + 0.15346315752484826, + 0.32687664998404986, + 1.1173618829561318 + ] + }, + { + "t": 2.2684226822363764, + "v": [ + -31.58476211869586, + -3.710798107118315, + 24.071416454447956 + ], + "ease": [ + 0.36229770668640776, + -0.17418027527991467, + 0.2875659277885649, + 1.2452842602722636 + ] + }, + { + "t": 2.7962446921443735, + "v": [ + -27.874087487241205, + 0.739249290532289, + 5.007074162025872 + ], + "ease": [ + 0.42, + 0.044881803684311196, + 0.58, + 1 + ] + }, + { + "t": 3.2725893489030398, + "v": [ + -19.255583925594408, + -17.414119894980235, + -7.023063437300964 + ], + "ease": [ + 0.49415439617951884, + -0.031041466813226757, + 0.2907565775851124, + 1 + ] + }, + { + "t": 3.8435756138392856, + "v": [ + -10.064416055441825, + 12.982763466855692, + 19.192265328801522 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 3.9805733816964284, + "v": [ + -15.896271043294888, + 33.29315635042974, + 25.38480127460161 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "expression": [ + { + "t": 0.05960518973214285, + "v": "neutral", + "ease": [ + 0.7416814271255061, + 0.03220016891891886, + 0.26459703947368424, + 1.0216427364864864 + ] + }, + { + "t": 2.2434315286624202, + "v": "surprised", + "ease": [ + 0.7416814271255061, + 0.03220016891891886, + 0.26459703947368424, + 1.0216427364864864 + ] + } + ] + } + }, + { + "name": "Working_F", + "duration": 5, + "body": "sphere", + "squashAnchor": 0, + "workArea": null, + "inspect": { + "bias": -1, + "follow": true + }, + "tracks": { + "position": [ + { + "t": 0.4874467656537753, + "v": [ + 0, + 0 + ], + "ease": [ + 0.6778358120868745, + -0.22439715100065685, + 0.15951040179591214, + 1 + ] + }, + { + "t": 1.3832000000000173, + "v": [ + -0.14647919001828796, + 0.027266090496135924 + ], + "ease": [ + 0.5825263567433967, + 0.31017058172003364, + 0.04617913732408452, + 1.3610608114532912 + ] + }, + { + "t": 1.77965296961326, + "v": [ + -0.14647919001828796, + 0.027266090496135924 + ], + "ease": [ + 0.22005965712189499, + 0.31017058172003364, + 0.04617913732408452, + 0.9652306378531971 + ] + }, + { + "t": 2.5961328343722427, + "v": [ + 0.06209379465135341, + 0.0363636826260471 + ], + "ease": [ + 0.6823010997778416, + 0.03975119923438924, + 0.58, + 0.7462988396745895 + ] + }, + { + "t": 3.066377943544433, + "v": [ + -0.016487420591570615, + 0.0804209848791122 + ], + "ease": [ + 0.8034080182827295, + -0.08276325518488785, + 0.6510110561346553, + 0.8128258749805829 + ] + }, + { + "t": 3.426085780646072, + "v": [ + -0.008916270094194876, + -0.2135819760518346 + ], + "ease": [ + 0.1744021985074266, + -0.5, + 0.8865519014643501, + -0.2214168917492133 + ] + }, + { + "t": 4.00439785911593, + "v": [ + -0.0052248049102180776, + -0.12515593913381462 + ], + "ease": [ + 0.023812816055625345, + 0.31341394515796384, + 0.0448947139696587, + 1.0658309377957906 + ] + }, + { + "t": 4.893926749555462, + "v": [ + 0, + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ], + "rotation": [ + { + "t": 0.4874467656537753, + "v": [ + 0, + 0, + 0 + ], + "ease": [ + 0.6778358120868745, + -0.22439715100065685, + 0.15951040179591214, + 1 + ] + }, + { + "t": 1.3832000000000173, + "v": [ + -11.013174031248813, + -20.059833580125414, + -12.896783267756348 + ], + "ease": [ + 0.5825263567433967, + 0.31017058172003364, + 0.04617913732408452, + 1.3610608114532912 + ] + }, + { + "t": 1.77965296961326, + "v": [ + -17.48975478197329, + -18.703976724880516, + -12.678205332257816 + ], + "ease": [ + 0.22005965712189499, + 0.31017058172003364, + 0.04617913732408452, + 0.9652306378531971 + ] + }, + { + "t": 2.5961328343722427, + "v": [ + -14.554913391638532, + 17.164836260093473, + 9.376528927180779 + ], + "ease": [ + 0.6823010997778416, + 0.03975119923438924, + 0.58, + 0.9240336563778733 + ] + }, + { + "t": 3.1796016343940767, + "v": [ + -24.586251970533304, + 0.14133926125606477, + -0.7477216271856462 + ], + "ease": [ + 0.8034080182827295, + -0.08276325518488785, + 0.9143306014396848, + 0.4851034680582119 + ] + }, + { + "t": 3.4679142027965795, + "v": [ + -1.0943281912285556, + -1.1346215368163393, + -0.7116952620126241 + ], + "ease": [ + 0.12040927423864656, + -0.18872517040581954, + 0.9481776569383256, + -0.20917925086224787 + ] + }, + { + "t": 4.001861402631413, + "v": [ + -147.11069531984157, + -0.6757806869448113, + -0.4238857605398247 + ], + "ease": [ + 0.03131913716814155, + 0.8644907690193963, + 0.0448947139696587, + 1.0658309377957906 + ] + }, + { + "t": 4.893926749555462, + "v": [ + -360, + 0, + 0 + ], + "ease": [ + 0.7970933306500759, + -0.5, + 0.24118105710043447, + 0.7361553789450831 + ] + } + ], + "expression": [ + { + "t": 1.8508295659644571, + "v": "surprised", + "ease": [ + 0.7394673582995951, + 0.10409628378378377, + 0.6676682692307693, + 1.5 + ] + }, + { + "t": 4.893926749555462, + "v": "neutral", + "ease": [ + 0.8458217358299596, + 0.40487753378378377, + 0.13769926619433198, + 0.9154349662162162 + ], + "cut": true + } + ], + "lids": [ + { + "t": 1.1672000000000002, + "v": [ + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 1.2797000000000003, + "v": [ + 1 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + { + "t": 1.4172000000000002, + "v": [ + 0 + ], + "ease": [ + 0.42, + 0, + 0.58, + 1 + ] + } + ] + } + } + ], + "states": { + "idle": { + "clips": [ + "Idle_B", + "Idle_B", + "Idle_D", + "Idle_B", + "Idle_A" + ], + "compactClips": [ + "Idle_B" + ] + }, + "listening": { + "clips": [ + "Idle_D" + ], + "face": "calm" + }, + "thinking": { + "clips": [ + "Idle_B" + ], + "morph": "thinking" + }, + "streaming": { + "clips": [ + "Streaming_A", + "Streaming_B", + "Streaming_C" + ] + }, + "searching": { + "clips": [ + "Idle_B" + ], + "morph": "radar" + }, + "reading": { + "clips": [ + "Working_A", + "Working_B", + "Working_C", + "Working_D", + "Working_E", + "Working_F" + ] + }, + "working": { + "clips": [ + "Working_A", + "Working_B", + "Working_C", + "Working_D", + "Working_E", + "Working_F" + ] + }, + "coding": { + "clips": [ + "Working_A", + "Working_B", + "Working_C", + "Working_D", + "Working_E", + "Working_F" + ] + }, + "running": { + "clips": [ + "Working_A", + "Working_B", + "Working_C", + "Working_D", + "Working_E", + "Working_F" + ] + }, + "computing": { + "clips": [ + "Working_A", + "Working_B", + "Working_C", + "Working_D", + "Working_E", + "Working_F" + ] + }, + "controlling": { + "clips": [ + "Working_A", + "Working_B", + "Working_C", + "Working_D", + "Working_E", + "Working_F" + ] + }, + "waiting": { + "clips": [ + "Idle_B" + ], + "morph": "orbit" + }, + "blocked": { + "clips": [ + "Blocked_A" + ], + "next": "drowsy", + "holdSeconds": 120 + }, + "finished": { + "clips": [ + "Done_B", + "Done_A" + ], + "face": "happy", + "next": "idle" + }, + "failed": { + "clips": [ + "Blocked_B" + ], + "next": "idle" + }, + "sleeping": { + "clips": [ + "Idle_B" + ], + "face": "sleepy" + }, + "waking": { + "clips": [ + "Idle_B" + ], + "face": "surprised", + "next": "idle", + "holdSeconds": 1.6 + }, + "happy": { + "clips": [ + "Done_A" + ] + }, + "excited": { + "clips": [ + "Done_C" + ] + }, + "laughing": { + "clips": [ + "Done_B" + ] + }, + "proud": { + "clips": [ + "Marketing_B" + ] + }, + "curious": { + "clips": [ + "Working_B" + ], + "face": "inspecting" + }, + "playful": { + "clips": [ + "Bodymoves_A" + ] + }, + "shy": { + "clips": [ + "Idle_D" + ], + "face": "calm" + }, + "surprised": { + "clips": [ + "Idle_B" + ], + "face": "surprised" + }, + "suspicious": { + "clips": [ + "Idle_D" + ], + "face": "squinted" + }, + "angry": { + "clips": [ + "Blocked_A" + ], + "face": "squinted" + }, + "confused": { + "clips": [ + "Blocked_A" + ] + }, + "sad": { + "clips": [ + "Blocked_B" + ] + }, + "drowsy": { + "clips": [ + "Blocked_C" + ] + }, + "bored": { + "clips": [ + "Idle_B" + ], + "face": "sleepy" + }, + "scared": { + "clips": [ + "Idle_D" + ], + "face": "surprised" + }, + "celebrate": { + "clips": [ + "Rotation_A" + ], + "face": "happy" + }, + "approving": { + "clips": [ + "Rotation_C" + ], + "next": "idle" + }, + "orbit": { + "clips": [ + "Idle_B" + ], + "morph": "orbit" + }, + "radar": { + "clips": [ + "Idle_B" + ], + "morph": "radar" + }, + "progress": { + "clips": [ + "Idle_B" + ], + "morph": "progress" + }, + "spawning": { + "clips": [ + "Idle_B" + ], + "morph": "spawning" + }, + "loading": { + "clips": [ + "Idle_B" + ], + "morph": "progress" + }, + "dictating": { + "clips": [ + "Idle_B" + ], + "morph": "dictating" + }, + "sending": { + "clips": [ + "Idle_B" + ], + "morph": "sending" + }, + "receiving": { + "clips": [ + "Idle_B" + ], + "morph": "receiving" + }, + "uploading": { + "clips": [ + "Idle_B" + ], + "morph": "uploading" + }, + "writing": { + "clips": [ + "Idle_B" + ], + "morph": "writing" + }, + "alerting": { + "clips": [ + "Idle_B" + ], + "morph": "alerting" + }, + "bouncing": { + "clips": [ + "Idle_B" + ], + "morph": "bouncing" + }, + "powering-down": { + "clips": [ + "Idle_B" + ], + "morph": "powering-down" + }, + "humming": { + "clips": [ + "Idle_B" + ], + "face": "calm" + }, + "notifying": { + "clips": [ + "Done_B" + ], + "face": "surprised" + }, + "dragging": { + "clips": [ + "Idle_B" + ], + "face": "surprised" + }, + "logo": { + "clips": [ + "Logo_A", + "Logo_B" + ] + }, + "showcase": { + "clips": [ + "Marketing_A", + "Marketing_B", + "Marketing_C", + "Marketing_D" + ] + }, + "turntable": { + "clips": [ + "Rotation_B" + ] + } + }, + "lifecycleGroups": [ + { + "label": "Lifecycle", + "states": [ + "idle", + "listening", + "thinking", + "streaming", + "searching", + "reading", + "working", + "coding", + "running", + "computing", + "controlling", + "waiting", + "blocked", + "finished", + "failed", + "sleeping", + "waking" + ] + }, + { + "label": "Reactions", + "states": [ + "happy", + "excited", + "laughing", + "proud", + "curious", + "playful", + "shy", + "surprised", + "suspicious", + "angry", + "confused", + "sad", + "drowsy", + "bored", + "scared", + "celebrate", + "approving" + ] + }, + { + "label": "Morphs", + "states": [ + "orbit", + "radar", + "progress", + "spawning", + "loading", + "dictating", + "sending", + "receiving", + "uploading", + "writing", + "alerting", + "bouncing", + "powering-down" + ] + }, + { + "label": "Product", + "states": [ + "humming", + "notifying", + "dragging" + ] + }, + { + "label": "Brand", + "states": [ + "logo", + "showcase", + "turntable" + ] + } + ], + "expressions": { + "neutral": [ + { + "off": [ + 0, + 0 + ], + "tilt": 0, + "wid": 0.13, + "lidY": 1, + "oval": [ + 0, + 0 + ], + "ovalMix": 0, + "pts": [ + [ + 0, + -0.15499999999999997 + ], + [ + 0, + -0.1395 + ], + [ + 0, + -0.12400000000000001 + ], + [ + 0, + -0.10849999999999996 + ], + [ + 0, + -0.09299999999999997 + ], + [ + 0, + -0.07749999999999999 + ], + [ + 0, + -0.062 + ], + [ + 0, + -0.046499999999999986 + ], + [ + 0, + -0.030999999999999986 + ], + [ + 0, + -0.015499999999999993 + ], + [ + 0, + 0 + ], + [ + 0, + 0.015499999999999998 + ], + [ + 0, + 0.031000000000000003 + ], + [ + 0, + 0.046499999999999986 + ], + [ + 0, + 0.061999999999999986 + ], + [ + 0, + 0.07749999999999999 + ], + [ + 0, + 0.093 + ], + [ + 0, + 0.10849999999999996 + ], + [ + 0, + 0.12399999999999999 + ], + [ + 0, + 0.13949999999999999 + ], + [ + 0, + 0.15499999999999997 + ] + ], + "path": [ + 0, + -0.15499999999999997, + 0, + 0, + 0, + 0.05166666666666665, + 0, + 0, + 0, + -0.05166666666666665, + 0, + 0.05166666666666665, + 0, + 0.15499999999999997, + 0, + -0.05166666666666665, + 0, + 0 + ], + "fillRing": null + }, + { + "off": [ + 0, + 0 + ], + "tilt": 0, + "wid": 0.13, + "lidY": 1, + "oval": [ + 0, + 0 + ], + "ovalMix": 0, + "pts": [ + [ + 0, + -0.15499999999999997 + ], + [ + 0, + -0.1395 + ], + [ + 0, + -0.12400000000000001 + ], + [ + 0, + -0.10849999999999996 + ], + [ + 0, + -0.09299999999999997 + ], + [ + 0, + -0.07749999999999999 + ], + [ + 0, + -0.062 + ], + [ + 0, + -0.046499999999999986 + ], + [ + 0, + -0.030999999999999986 + ], + [ + 0, + -0.015499999999999993 + ], + [ + 0, + 0 + ], + [ + 0, + 0.015499999999999998 + ], + [ + 0, + 0.031000000000000003 + ], + [ + 0, + 0.046499999999999986 + ], + [ + 0, + 0.061999999999999986 + ], + [ + 0, + 0.07749999999999999 + ], + [ + 0, + 0.093 + ], + [ + 0, + 0.10849999999999996 + ], + [ + 0, + 0.12399999999999999 + ], + [ + 0, + 0.13949999999999999 + ], + [ + 0, + 0.15499999999999997 + ] + ], + "path": [ + 0, + -0.15499999999999997, + 0, + 0, + 0, + 0.05166666666666665, + 0, + 0, + 0, + -0.05166666666666665, + 0, + 0.05166666666666665, + 0, + 0.15499999999999997, + 0, + -0.05166666666666665, + 0, + 0 + ], + "fillRing": null + } + ], + "happy": [ + { + "off": [ + 0.03, + 0.03 + ], + "tilt": 0, + "wid": 0.1305, + "lidY": 1, + "oval": [ + 0, + 0 + ], + "ovalMix": 0, + "pts": [ + [ + 0.16, + -0.04 + ], + [ + 0.15790587011926904, + -0.014047168926578759 + ], + [ + 0.15184309375740104, + 0.010572375029603944 + ], + [ + 0.14214109083488316, + 0.03352921194806077 + ], + [ + 0.12912928127220297, + 0.054493921908304425 + ], + [ + 0.1131370849898476, + 0.0731370849898476 + ], + [ + 0.09449392190830445, + 0.08912928127220296 + ], + [ + 0.07352921194806078, + 0.10214109083488318 + ], + [ + 0.05057237502960394, + 0.111843093757401 + ], + [ + 0.02595283107342125, + 0.11790587011926902 + ], + [ + 9.797174393178826e-18, + 0.12 + ], + [ + -0.025952831073421237, + 0.11790587011926904 + ], + [ + -0.05057237502960395, + 0.11184309375740102 + ], + [ + -0.07352921194806077, + 0.10214109083488317 + ], + [ + -0.09449392190830441, + 0.08912928127220295 + ], + [ + -0.11313708498984759, + 0.0731370849898476 + ], + [ + -0.12912928127220297, + 0.054493921908304446 + ], + [ + -0.1421410908348832, + 0.033529211948060786 + ], + [ + -0.15184309375740102, + 0.010572375029603932 + ], + [ + -0.15790587011926904, + -0.014047168926578757 + ], + [ + -0.16, + -0.039999999999999994 + ] + ], + "path": [ + 0.16, + -0.04, + 0, + 0, + 0, + 0.08836555997292693, + 9.797174393178826e-18, + 0.12, + 0.08836555997292693, + 0, + -0.08836555997292693, + 0, + -0.16, + -0.039999999999999994, + 0, + 0.08836555997292693, + 0, + 0 + ], + "fillRing": null + }, + { + "off": [ + 0.03, + 0.03 + ], + "tilt": 0, + "wid": 0.1305, + "lidY": 1, + "oval": [ + 0, + 0 + ], + "ovalMix": 0, + "pts": [ + [ + 0.16, + -0.04 + ], + [ + 0.15790587011926904, + -0.014047168926578759 + ], + [ + 0.15184309375740104, + 0.010572375029603944 + ], + [ + 0.14214109083488316, + 0.03352921194806077 + ], + [ + 0.12912928127220297, + 0.054493921908304425 + ], + [ + 0.1131370849898476, + 0.0731370849898476 + ], + [ + 0.09449392190830445, + 0.08912928127220296 + ], + [ + 0.07352921194806078, + 0.10214109083488318 + ], + [ + 0.05057237502960394, + 0.111843093757401 + ], + [ + 0.02595283107342125, + 0.11790587011926902 + ], + [ + 9.797174393178826e-18, + 0.12 + ], + [ + -0.025952831073421237, + 0.11790587011926904 + ], + [ + -0.05057237502960395, + 0.11184309375740102 + ], + [ + -0.07352921194806077, + 0.10214109083488317 + ], + [ + -0.09449392190830441, + 0.08912928127220295 + ], + [ + -0.11313708498984759, + 0.0731370849898476 + ], + [ + -0.12912928127220297, + 0.054493921908304446 + ], + [ + -0.1421410908348832, + 0.033529211948060786 + ], + [ + -0.15184309375740102, + 0.010572375029603932 + ], + [ + -0.15790587011926904, + -0.014047168926578757 + ], + [ + -0.16, + -0.039999999999999994 + ] + ], + "path": [ + 0.16, + -0.04, + 0, + 0, + 0, + 0.08836555997292693, + 9.797174393178826e-18, + 0.12, + 0.08836555997292693, + 0, + -0.08836555997292693, + 0, + -0.16, + -0.039999999999999994, + 0, + 0.08836555997292693, + 0, + 0 + ], + "fillRing": null + } + ], + "surprised": [ + { + "off": [ + 0.03, + 0 + ], + "tilt": 0, + "wid": 0.21, + "lidY": 1, + "oval": [ + 0, + 0 + ], + "ovalMix": 0, + "pts": [ + [ + 0, + -0.22 + ], + [ + 0, + -0.19800000000000004 + ], + [ + 0, + -0.17600000000000005 + ], + [ + 0, + -0.15399999999999997 + ], + [ + 0, + -0.13199999999999998 + ], + [ + 0, + -0.11 + ], + [ + 0, + -0.08800000000000002 + ], + [ + 0, + -0.066 + ], + [ + 0, + -0.04399999999999999 + ], + [ + 0, + -0.022 + ], + [ + 0, + 0 + ], + [ + 0, + 0.022000000000000006 + ], + [ + 0, + 0.04400000000000001 + ], + [ + 0, + 0.06599999999999999 + ], + [ + 0, + 0.088 + ], + [ + 0, + 0.11 + ], + [ + 0, + 0.132 + ], + [ + 0, + 0.15399999999999997 + ], + [ + 0, + 0.17600000000000002 + ], + [ + 0, + 0.198 + ], + [ + 0, + 0.22 + ] + ], + "path": [ + 0, + -0.22, + 0, + 0, + 0, + 0.07333333333333333, + 0, + 0, + 0, + -0.07333333333333333, + 0, + 0.07333333333333333, + 0, + 0.22, + 0, + -0.07333333333333333, + 0, + 0 + ], + "fillRing": null + }, + { + "off": [ + 0.03, + 0 + ], + "tilt": 0, + "wid": 0.21, + "lidY": 1, + "oval": [ + 0, + 0 + ], + "ovalMix": 0, + "pts": [ + [ + 0, + -0.22 + ], + [ + 0, + -0.19800000000000004 + ], + [ + 0, + -0.17600000000000005 + ], + [ + 0, + -0.15399999999999997 + ], + [ + 0, + -0.13199999999999998 + ], + [ + 0, + -0.11 + ], + [ + 0, + -0.08800000000000002 + ], + [ + 0, + -0.066 + ], + [ + 0, + -0.04399999999999999 + ], + [ + 0, + -0.022 + ], + [ + 0, + 0 + ], + [ + 0, + 0.022000000000000006 + ], + [ + 0, + 0.04400000000000001 + ], + [ + 0, + 0.06599999999999999 + ], + [ + 0, + 0.088 + ], + [ + 0, + 0.11 + ], + [ + 0, + 0.132 + ], + [ + 0, + 0.15399999999999997 + ], + [ + 0, + 0.17600000000000002 + ], + [ + 0, + 0.198 + ], + [ + 0, + 0.22 + ] + ], + "path": [ + 0, + -0.22, + 0, + 0, + 0, + 0.07333333333333333, + 0, + 0, + 0, + -0.07333333333333333, + 0, + 0.07333333333333333, + 0, + 0.22, + 0, + -0.07333333333333333, + 0, + 0 + ], + "fillRing": null + } + ], + "inspecting": [ + { + "off": [ + 0.04, + 0 + ], + "tilt": 0, + "wid": 0.27, + "lidY": 1, + "oval": [ + 0, + 0 + ], + "ovalMix": 0, + "pts": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "path": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "fillRing": null + }, + { + "off": [ + 0.04, + 0 + ], + "tilt": 0, + "wid": 0.18, + "lidY": 1, + "oval": [ + 0, + 0 + ], + "ovalMix": 0, + "pts": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "path": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "fillRing": null + } + ], + "sad": [ + { + "off": [ + -0.05, + 0 + ], + "tilt": 0, + "wid": 0.13, + "lidY": 1, + "oval": [ + 0, + 0 + ], + "ovalMix": 0, + "pts": [ + [ + 0.0563365875343069, + -0.10595371114307124 + ], + [ + 0.05070292878087621, + -0.09535834002876413 + ], + [ + 0.04506927002744553, + -0.08476296891445702 + ], + [ + 0.03943561127401482, + -0.07416759780014985 + ], + [ + 0.033801952520584135, + -0.06357222668584275 + ], + [ + 0.02816829376715345, + -0.05297685557153562 + ], + [ + 0.022534635013722763, + -0.0423814844572285 + ], + [ + 0.01690097626029207, + -0.03178611334292138 + ], + [ + 0.011267317506861375, + -0.021190742228614244 + ], + [ + 0.005633658753430688, + -0.010595371114307122 + ], + [ + 0, + 0 + ], + [ + -0.005633658753430689, + 0.010595371114307124 + ], + [ + -0.011267317506861382, + 0.021190742228614255 + ], + [ + -0.016900976260292064, + 0.03178611334292137 + ], + [ + -0.022534635013722756, + 0.0423814844572285 + ], + [ + -0.02816829376715345, + 0.05297685557153562 + ], + [ + -0.03380195252058414, + 0.06357222668584275 + ], + [ + -0.03943561127401482, + 0.07416759780014986 + ], + [ + -0.04506927002744553, + 0.084762968914457 + ], + [ + -0.05070292878087621, + 0.09535834002876413 + ], + [ + -0.0563365875343069, + 0.10595371114307124 + ] + ], + "path": [ + 0.0563365875343069, + -0.10595371114307124, + 0, + 0, + -0.01877886251143563, + 0.03531790371435707, + 0, + 0, + 0.01877886251143563, + -0.03531790371435708, + -0.01877886251143563, + 0.03531790371435708, + -0.0563365875343069, + 0.10595371114307124, + 0.01877886251143563, + -0.03531790371435707, + 0, + 0 + ], + "fillRing": null + }, + { + "off": [ + -0.05, + 0 + ], + "tilt": 0, + "wid": 0.13, + "lidY": 1, + "oval": [ + 0, + 0 + ], + "ovalMix": 0, + "pts": [ + [ + 0.0563365875343069, + -0.10595371114307124 + ], + [ + 0.05070292878087621, + -0.09535834002876413 + ], + [ + 0.04506927002744553, + -0.08476296891445702 + ], + [ + 0.03943561127401482, + -0.07416759780014985 + ], + [ + 0.033801952520584135, + -0.06357222668584275 + ], + [ + 0.02816829376715345, + -0.05297685557153562 + ], + [ + 0.022534635013722763, + -0.0423814844572285 + ], + [ + 0.01690097626029207, + -0.03178611334292138 + ], + [ + 0.011267317506861375, + -0.021190742228614244 + ], + [ + 0.005633658753430688, + -0.010595371114307122 + ], + [ + 0, + 0 + ], + [ + -0.005633658753430689, + 0.010595371114307124 + ], + [ + -0.011267317506861382, + 0.021190742228614255 + ], + [ + -0.016900976260292064, + 0.03178611334292137 + ], + [ + -0.022534635013722756, + 0.0423814844572285 + ], + [ + -0.02816829376715345, + 0.05297685557153562 + ], + [ + -0.03380195252058414, + 0.06357222668584275 + ], + [ + -0.03943561127401482, + 0.07416759780014986 + ], + [ + -0.04506927002744553, + 0.084762968914457 + ], + [ + -0.05070292878087621, + 0.09535834002876413 + ], + [ + -0.0563365875343069, + 0.10595371114307124 + ] + ], + "path": [ + 0.0563365875343069, + -0.10595371114307124, + 0, + 0, + -0.01877886251143563, + 0.03531790371435707, + 0, + 0, + 0.01877886251143563, + -0.03531790371435708, + -0.01877886251143563, + 0.03531790371435708, + -0.0563365875343069, + 0.10595371114307124, + 0.01877886251143563, + -0.03531790371435707, + 0, + 0 + ], + "fillRing": null + } + ], + "sleepy": [ + { + "off": [ + 0.01, + 0 + ], + "tilt": 0, + "wid": 0.13, + "lidY": 1, + "oval": [ + 0, + 0 + ], + "ovalMix": 0, + "pts": [ + [ + -0.11, + 0.025 + ], + [ + -0.09900000000000002, + 0.017875000000000002 + ], + [ + -0.08800000000000002, + 0.011500000000000003 + ], + [ + -0.07699999999999999, + 0.005874999999999997 + ], + [ + -0.06599999999999999, + 0.001 + ], + [ + -0.055, + -0.0031250000000000006 + ], + [ + -0.04400000000000001, + -0.006500000000000001 + ], + [ + -0.033, + -0.009124999999999998 + ], + [ + -0.021999999999999995, + -0.011000000000000003 + ], + [ + -0.011, + -0.012125 + ], + [ + 0, + -0.0125 + ], + [ + 0.011000000000000003, + -0.012125 + ], + [ + 0.022000000000000006, + -0.011000000000000005 + ], + [ + 0.032999999999999995, + -0.009124999999999998 + ], + [ + 0.044, + -0.006499999999999999 + ], + [ + 0.055, + -0.003125 + ], + [ + 0.066, + 0.0009999999999999992 + ], + [ + 0.07699999999999999, + 0.0058749999999999965 + ], + [ + 0.08800000000000001, + 0.011500000000000005 + ], + [ + 0.099, + 0.017875000000000002 + ], + [ + 0.11, + 0.025 + ] + ], + "path": [ + -0.11, + 0.025, + 0, + 0, + 0.03666666666666667, + -0.025, + 0, + -0.0125, + -0.03666666666666667, + 0, + 0.03666666666666667, + 0, + 0.11, + 0.025, + -0.03666666666666667, + -0.025, + 0, + 0 + ], + "fillRing": null + }, + { + "off": [ + 0.01, + 0 + ], + "tilt": 0, + "wid": 0.13, + "lidY": 1, + "oval": [ + 0, + 0 + ], + "ovalMix": 0, + "pts": [ + [ + -0.11, + 0.025 + ], + [ + -0.09900000000000002, + 0.017875000000000002 + ], + [ + -0.08800000000000002, + 0.011500000000000003 + ], + [ + -0.07699999999999999, + 0.005874999999999997 + ], + [ + -0.06599999999999999, + 0.001 + ], + [ + -0.055, + -0.0031250000000000006 + ], + [ + -0.04400000000000001, + -0.006500000000000001 + ], + [ + -0.033, + -0.009124999999999998 + ], + [ + -0.021999999999999995, + -0.011000000000000003 + ], + [ + -0.011, + -0.012125 + ], + [ + 0, + -0.0125 + ], + [ + 0.011000000000000003, + -0.012125 + ], + [ + 0.022000000000000006, + -0.011000000000000005 + ], + [ + 0.032999999999999995, + -0.009124999999999998 + ], + [ + 0.044, + -0.006499999999999999 + ], + [ + 0.055, + -0.003125 + ], + [ + 0.066, + 0.0009999999999999992 + ], + [ + 0.07699999999999999, + 0.0058749999999999965 + ], + [ + 0.08800000000000001, + 0.011500000000000005 + ], + [ + 0.099, + 0.017875000000000002 + ], + [ + 0.11, + 0.025 + ] + ], + "path": [ + -0.11, + 0.025, + 0, + 0, + 0.03666666666666667, + -0.025, + 0, + -0.0125, + -0.03666666666666667, + 0, + 0.03666666666666667, + 0, + 0.11, + 0.025, + -0.03666666666666667, + -0.025, + 0, + 0 + ], + "fillRing": null + } + ], + "squinted": [ + { + "off": [ + 0.01, + 0 + ], + "tilt": 0, + "wid": 0.13, + "lidY": 1, + "oval": [ + 0, + 0 + ], + "ovalMix": 0, + "pts": [ + [ + -0.12, + -7.347880794884118e-18 + ], + [ + -0.108, + -6.613092715395708e-18 + ], + [ + -0.09600000000000002, + -5.878304635907297e-18 + ], + [ + -0.08399999999999998, + -5.143516556418882e-18 + ], + [ + -0.072, + -4.408728476930471e-18 + ], + [ + -0.06, + -3.673940397442059e-18 + ], + [ + -0.048, + -2.939152317953648e-18 + ], + [ + -0.036000000000000004, + -2.2043642384652355e-18 + ], + [ + -0.023999999999999994, + -1.4695761589768233e-18 + ], + [ + -0.011999999999999997, + -7.347880794884116e-19 + ], + [ + 0, + 0 + ], + [ + 0.012, + 7.347880794884118e-19 + ], + [ + 0.024000000000000004, + 1.4695761589768243e-18 + ], + [ + 0.03599999999999999, + 2.204364238465235e-18 + ], + [ + 0.047999999999999994, + 2.9391523179536477e-18 + ], + [ + 0.06, + 3.673940397442059e-18 + ], + [ + 0.07200000000000001, + 4.408728476930472e-18 + ], + [ + 0.08399999999999999, + 5.143516556418882e-18 + ], + [ + 0.096, + 5.878304635907296e-18 + ], + [ + 0.108, + 6.613092715395707e-18 + ], + [ + 0.12, + 7.347880794884118e-18 + ] + ], + "path": [ + -0.12, + -7.347880794884118e-18, + 0, + 0, + 0.039999999999999994, + 2.4492935982947056e-18, + 0, + 0, + -0.039999999999999994, + -2.449293598294706e-18, + 0.039999999999999994, + 2.449293598294706e-18, + 0.12, + 7.347880794884118e-18, + -0.039999999999999994, + -2.4492935982947056e-18, + 0, + 0 + ], + "fillRing": null + }, + { + "off": [ + 0.01, + 0 + ], + "tilt": 0, + "wid": 0.13, + "lidY": 1, + "oval": [ + 0, + 0 + ], + "ovalMix": 0, + "pts": [ + [ + -0.12, + -7.347880794884118e-18 + ], + [ + -0.108, + -6.613092715395708e-18 + ], + [ + -0.09600000000000002, + -5.878304635907297e-18 + ], + [ + -0.08399999999999998, + -5.143516556418882e-18 + ], + [ + -0.072, + -4.408728476930471e-18 + ], + [ + -0.06, + -3.673940397442059e-18 + ], + [ + -0.048, + -2.939152317953648e-18 + ], + [ + -0.036000000000000004, + -2.2043642384652355e-18 + ], + [ + -0.023999999999999994, + -1.4695761589768233e-18 + ], + [ + -0.011999999999999997, + -7.347880794884116e-19 + ], + [ + 0, + 0 + ], + [ + 0.012, + 7.347880794884118e-19 + ], + [ + 0.024000000000000004, + 1.4695761589768243e-18 + ], + [ + 0.03599999999999999, + 2.204364238465235e-18 + ], + [ + 0.047999999999999994, + 2.9391523179536477e-18 + ], + [ + 0.06, + 3.673940397442059e-18 + ], + [ + 0.07200000000000001, + 4.408728476930472e-18 + ], + [ + 0.08399999999999999, + 5.143516556418882e-18 + ], + [ + 0.096, + 5.878304635907296e-18 + ], + [ + 0.108, + 6.613092715395707e-18 + ], + [ + 0.12, + 7.347880794884118e-18 + ] + ], + "path": [ + -0.12, + -7.347880794884118e-18, + 0, + 0, + 0.039999999999999994, + 2.4492935982947056e-18, + 0, + 0, + -0.039999999999999994, + -2.449293598294706e-18, + 0.039999999999999994, + 2.449293598294706e-18, + 0.12, + 7.347880794884118e-18, + -0.039999999999999994, + -2.4492935982947056e-18, + 0, + 0 + ], + "fillRing": null + } + ], + "calm": [ + { + "off": [ + 0.05, + 0 + ], + "tilt": 0, + "wid": 0.247, + "lidY": 1, + "oval": [ + 0.296, + 0.247 + ], + "ovalMix": 0, + "pts": [ + [ + -0.296, + 0 + ], + [ + -0.2664, + 0 + ], + [ + -0.23680000000000004, + 0 + ], + [ + -0.20719999999999994, + 0 + ], + [ + -0.17759999999999998, + 0 + ], + [ + -0.14799999999999996, + 0 + ], + [ + -0.1184, + 0 + ], + [ + -0.08879999999999999, + 0 + ], + [ + -0.05919999999999998, + 0 + ], + [ + -0.02959999999999999, + 0 + ], + [ + 0, + 0 + ], + [ + 0.029599999999999998, + 0 + ], + [ + 0.05920000000000001, + 0 + ], + [ + 0.08879999999999998, + 0 + ], + [ + 0.11839999999999999, + 0 + ], + [ + 0.148, + 0 + ], + [ + 0.17759999999999998, + 0 + ], + [ + 0.20719999999999994, + 0 + ], + [ + 0.2368, + 0 + ], + [ + 0.26639999999999997, + 0 + ], + [ + 0.296, + 0 + ] + ], + "path": [ + -0.296, + 0, + 0, + 0, + 0.09866666666666665, + 0, + 0, + 0, + -0.09866666666666665, + 0, + 0.09866666666666665, + 0, + 0.296, + 0, + -0.09866666666666665, + 0, + 0, + 0 + ], + "fillRing": null + }, + { + "off": [ + 0.05, + 0 + ], + "tilt": 0, + "wid": 0.247, + "lidY": 1, + "oval": [ + 0.296, + 0.247 + ], + "ovalMix": 0, + "pts": [ + [ + -0.296, + 0 + ], + [ + -0.2664, + 0 + ], + [ + -0.23680000000000004, + 0 + ], + [ + -0.20719999999999994, + 0 + ], + [ + -0.17759999999999998, + 0 + ], + [ + -0.14799999999999996, + 0 + ], + [ + -0.1184, + 0 + ], + [ + -0.08879999999999999, + 0 + ], + [ + -0.05919999999999998, + 0 + ], + [ + -0.02959999999999999, + 0 + ], + [ + 0, + 0 + ], + [ + 0.029599999999999998, + 0 + ], + [ + 0.05920000000000001, + 0 + ], + [ + 0.08879999999999998, + 0 + ], + [ + 0.11839999999999999, + 0 + ], + [ + 0.148, + 0 + ], + [ + 0.17759999999999998, + 0 + ], + [ + 0.20719999999999994, + 0 + ], + [ + 0.2368, + 0 + ], + [ + 0.26639999999999997, + 0 + ], + [ + 0.296, + 0 + ] + ], + "path": [ + -0.296, + 0, + 0, + 0, + 0.09866666666666665, + 0, + 0, + 0, + -0.09866666666666665, + 0, + 0.09866666666666665, + 0, + 0.296, + 0, + -0.09866666666666665, + 0, + 0, + 0 + ], + "fillRing": null + } + ], + "confused": [ + { + "off": [ + -0.1, + 0 + ], + "tilt": 0, + "wid": 0.1305, + "lidY": 1, + "oval": [ + 0, + 0 + ], + "ovalMix": 0, + "pts": [ + [ + 0.01, + 0.16 + ], + [ + -0.007099999999999999, + 0.14400000000000002 + ], + [ + -0.02240000000000001, + 0.12800000000000006 + ], + [ + -0.035899999999999994, + 0.11199999999999999 + ], + [ + -0.04759999999999999, + 0.09599999999999999 + ], + [ + -0.0575, + 0.07999999999999999 + ], + [ + -0.0656, + 0.06400000000000002 + ], + [ + -0.07189999999999999, + 0.048 + ], + [ + -0.0764, + 0.03199999999999999 + ], + [ + -0.0791, + 0.015999999999999997 + ], + [ + -0.08, + 0 + ], + [ + -0.07910000000000002, + -0.016 + ], + [ + -0.07640000000000002, + -0.032000000000000015 + ], + [ + -0.07189999999999998, + -0.04799999999999999 + ], + [ + -0.06559999999999999, + -0.064 + ], + [ + -0.057499999999999996, + -0.08 + ], + [ + -0.0476, + -0.096 + ], + [ + -0.0359, + -0.11199999999999999 + ], + [ + -0.02239999999999999, + -0.128 + ], + [ + -0.007099999999999995, + -0.14400000000000002 + ], + [ + 0.01, + -0.16 + ] + ], + "path": [ + 0.01, + 0.16, + 0, + 0, + -0.06, + -0.05333333333333333, + -0.08, + 0, + 0, + 0.05333333333333333, + 0, + -0.05333333333333333, + 0.01, + -0.16, + -0.06, + 0.05333333333333333, + 0, + 0 + ], + "fillRing": null + }, + { + "off": [ + -0.1, + 0 + ], + "tilt": 0, + "wid": 0.1305, + "lidY": 1, + "oval": [ + 0, + 0 + ], + "ovalMix": 0, + "pts": [ + [ + 0.01, + 0.16 + ], + [ + -0.007099999999999999, + 0.14400000000000002 + ], + [ + -0.02240000000000001, + 0.12800000000000006 + ], + [ + -0.035899999999999994, + 0.11199999999999999 + ], + [ + -0.04759999999999999, + 0.09599999999999999 + ], + [ + -0.0575, + 0.07999999999999999 + ], + [ + -0.0656, + 0.06400000000000002 + ], + [ + -0.07189999999999999, + 0.048 + ], + [ + -0.0764, + 0.03199999999999999 + ], + [ + -0.0791, + 0.015999999999999997 + ], + [ + -0.08, + 0 + ], + [ + -0.07910000000000002, + -0.016 + ], + [ + -0.07640000000000002, + -0.032000000000000015 + ], + [ + -0.07189999999999998, + -0.04799999999999999 + ], + [ + -0.06559999999999999, + -0.064 + ], + [ + -0.057499999999999996, + -0.08 + ], + [ + -0.0476, + -0.096 + ], + [ + -0.0359, + -0.11199999999999999 + ], + [ + -0.02239999999999999, + -0.128 + ], + [ + -0.007099999999999995, + -0.14400000000000002 + ], + [ + 0.01, + -0.16 + ] + ], + "path": [ + 0.01, + 0.16, + 0, + 0, + -0.06, + -0.05333333333333333, + -0.08, + 0, + 0, + 0.05333333333333333, + 0, + -0.05333333333333333, + 0.01, + -0.16, + -0.06, + 0.05333333333333333, + 0, + 0 + ], + "fillRing": null + } + ] + }, + "expressionNames": [ + "neutral", + "happy", + "surprised", + "sad", + "sleepy", + "squinted", + "calm", + "inspecting", + "confused" + ], + "expressionAliases": { + "thinking": "sad", + "strange": "inspecting", + "angry": "neutral", + "inspect": "inspecting", + "squint": "squinted", + "idle": "neutral", + "smile": "happy", + "sleep": "sleepy" + }, + "blinkBlockingExpressions": [ + "sad", + "happy", + "confused", + "squinted", + "sleepy" + ], + "shapes": { + "names": [ + "sphere", + "cloud", + "square", + "sparkle", + "clover", + "heartChubby", + "starFlower", + "teardrop", + "tablet", + "wedge", + "house", + "star6", + "pebble", + "bean", + "egg", + "squircle", + "capsule", + "cylinder", + "hex", + "gem", + "crystal", + "shield", + "dome", + "arch", + "leaf" + ], + "eyeMetrics": { + "cloud": { + "width": 0.21294, + "height": 0.4668299999999999, + "gap": 0.47001600000000004, + "shiftY": 0.06999999999999999 + }, + "square": { + "width": 0.2553258426966292, + "height": 0.559752808988764, + "gap": 0.529408, + "shiftY": 0.12 + }, + "sparkle": { + "width": 0.18381999999999998, + "height": 0.40298999999999996, + "gap": 0.41215999999999997, + "shiftY": 0 + }, + "clover": { + "width": 0.23296, + "height": 0.51072, + "gap": 0.559104, + "shiftY": -0.020000000000000018 + }, + "heartChubby": { + "width": 0.18657600000000002, + "height": 0.409032, + "gap": 0.44620799999999994, + "shiftY": 0.051999999999999935 + }, + "starFlower": { + "width": 0.19292, + "height": 0.42294, + "gap": 0.38297600000000004, + "shiftY": 0.12 + }, + "teardrop": { + "width": 0.20779200000000003, + "height": 0.455544, + "gap": 0.45260800000000007, + "shiftY": 0.18 + }, + "tablet": { + "width": 0.2132, + "height": 0.4673999999999999, + "gap": 0.587776, + "shiftY": 0.01999999999999999 + }, + "wedge": { + "width": 0.2132, + "height": 0.4673999999999999, + "gap": 0.46771199999999996, + "shiftY": 0.18 + }, + "house": { + "width": 0.2295124, + "height": 0.5031617999999999, + "gap": 0.5125120000000001, + "shiftY": 0.18499534000463813 + }, + "star6": { + "width": 0.184782, + "height": 0.44965988999999995, + "gap": 0.41913343999999997, + "shiftY": 0.12 + }, + "pebble": { + "width": 0.23920000000000002, + "height": 0.5244, + "gap": 0.546304, + "shiftY": 0.12 + }, + "bean": { + "width": 0.2028, + "height": 0.4446, + "gap": 0.4608, + "shiftY": 0.12 + }, + "egg": { + "width": 0.23920000000000002, + "height": 0.5244, + "gap": 0.433664, + "shiftY": 0.11299906800092761 + }, + "squircle": { + "width": 0.23920000000000002, + "height": 0.5244, + "gap": 0.5632, + "shiftY": 0.12 + }, + "capsule": { + "width": 0.2106, + "height": 0.4617, + "gap": 0.36044800000000005, + "shiftY": 0.12 + }, + "cylinder": { + "width": 0.23920000000000002, + "height": 0.5244, + "gap": 0.47872000000000003, + "shiftY": 0.12 + }, + "hex": { + "width": 0.26, + "height": 0.57, + "gap": 0.64, + "shiftY": 0.12 + }, + "gem": { + "width": 0.23920000000000002, + "height": 0.5244, + "gap": 0.501248, + "shiftY": 0.12 + }, + "crystal": { + "width": 0.1612, + "height": 0.3534, + "gap": 0.3712, + "shiftY": 0.12 + }, + "shield": { + "width": 0.26790400000000003, + "height": 0.5873280000000001, + "gap": 0.44492800000000005, + "shiftY": 0.12 + }, + "dome": { + "width": 0.2132, + "height": 0.4673999999999999, + "gap": 0.47872000000000003, + "shiftY": 0.10599813600185524 + }, + "arch": { + "width": 0.19240000000000002, + "height": 0.42179999999999995, + "gap": 0.44799999999999995, + "shiftY": 0.16 + }, + "leaf": { + "width": 0.1872, + "height": 0.41039999999999993, + "gap": 0.44799999999999995, + "shiftY": 0.039999999999999994 + } + }, + "aliases": { + "blob": "sphere", + "sphereWide": "sphere", + "arch": "horseshoe", + "spark": "sparkle", + "star": "star6", + "heart": "heartChubby", + "hexagon": "hex" + } + }, + "eyes": { + "Ja": [ + { + "A": [ + 0.01722, + 0.45638, + 0.88962 + ], + "B": [ + 0.186, + 0.20949, + 0.95996 + ], + "N": [ + 0.82924, + 0.4906, + -0.26773 + ] + }, + { + "A": [ + 0.48863, + 0.67924, + 0.54762 + ], + "B": [ + 0.65674, + 0.43195, + 0.61815 + ], + "N": [ + 0.60389, + 0.18972, + -0.77416 + ] + } + ], + "basis": [ + { + "M": [ + -0.3153457572898143, + 0.000413547232064401, + 0.948976755425548 + ], + "T": [ + -0.0012276245071586862, + 0.9999988905346685, + -0.000843722407896131 + ], + "C": [ + -0.948976051487804, + -0.0014310514033447285, + -0.31534489974391217 + ] + }, + { + "M": [ + 0.3153457572898147, + -0.000413547232064142, + 0.948976755425548 + ], + "T": [ + 0.0012179979536934692, + 0.9999992577584695, + 0.000031040210269326975 + ], + "C": [ + 0.9489760638921817, + -0.0011460633475971695, + -0.31534602692677954 + ] + } + ], + "mirror": [ + false, + true + ], + "baseQuatConjugate": [ + 0.9169956148992756, + -0.19158116627165414, + 0.24767406239432088, + 0.24713004229066535 + ], + "baseEulerDeg": [ + -15.896271043294888, + 33.29315635042974, + 25.38480127460161 + ], + "spineSamples": 21, + "pathSegments": 18, + "defaultWidth": 0.26, + "happyWidth": 0.1305 + }, + "aliases": { + "tracks": { + "position": "position", + "rotation": "rotation", + "squash": "squash", + "scale": "zoom", + "lids": "lids", + "expression": "expression", + "pos": "position", + "rot": "rotation", + "scl": "squash", + "zoom": "zoom", + "expr": "expression" + } + }, + "constants": { + "spring": { + "Jw": 0.008333333333333333 + }, + "durations": { + "blink": 0.26, + "morph": 0.28, + "lidMorph": 0.35, + "blinkClose": 0.07083333333333333, + "blinkHold": 0.03333333333333333, + "blinkOvershoot": 1.06 + }, + "ease": { + "nr": [ + 0.42, + 0, + 0.58, + 1 + ], + "tb": [ + 0.4, + 0.06, + 0.18, + 1 + ], + "dx": [ + 0.4, + 0, + 0.2, + 1 + ], + "vx": [ + 0.42, + 0, + 0.58, + 1 + ] + }, + "gaze": { + "Yb": 0.14, + "Zb": 0.55, + "Qb": { + "heartChubby": 0.6, + "wedge": 0.55 + }, + "Jb": 2, + "rxSphere": 14, + "rySphere": 18, + "rxShape": 19, + "ryShape": 26, + "pxSphere": 0.03, + "pySphere": 0.024, + "pxShape": 0.07, + "pyShape": 0.055 + }, + "runtime": { + "zoomDefault": 0.6, + "zoomMulClamp": [ + 0.2, + 3 + ], + "zoomClamp": [ + 0.25, + 2.5 + ], + "lookDuration": 0.5, + "maxAdvance": 0.1, + "bodySpringFreq": 10, + "bodyWeightEps": 0.0001, + "morphHideEyes": 0.5, + "gazeSnap": 0.001, + "restName": "rest", + "restClipDuration": 10 + }, + "bridge": { + "duration": 0.35, + "easeFrom": [ + 0.4, + 0, + 0.2, + 1 + ], + "easeTo": [ + 0.42, + 0, + 0.58, + 1 + ], + "tolerance": { + "rotDeg": 4, + "pos": 0.015, + "scl": 0.02, + "zoom": 0.02, + "lids": 0.05 + } + }, + "eyes": { + "Eb": 0.03, + "Pb": 0.6, + "Ab": 0.42, + "Ib": 20, + "JM": 0.02, + "eT": 16, + "tT": 0.015, + "nT": 400, + "rT": 512, + "ls": 5, + "outlineSamples": 192, + "arcStep": "Math.PI/48", + "lidSquashMin": 0.42, + "blinkThroughSet": [ + "sad", + "happy", + "confused", + "squinted", + "sleepy" + ] + }, + "glow": { + "ld": 48, + "dd": 11, + "gr": 0.055, + "_h": 3.1384510609362035, + "lT": 0.4, + "dT": 0.0035, + "fT": 0.75, + "hT": 0.45, + "pT": 1.8, + "mT": 0.8, + "midTBase": 0.42, + "midTAmp": 0.16, + "midTPeriodMs": 5403.539364174444 + }, + "poke": { + "Hb": 0.9, + "Vb": 0.4, + "Gb": 0.62, + "Wb": 0.3, + "Ub": 0.5, + "Kb": 16, + "spinBase": 0.9, + "spinPerTurn": 0.4 + }, + "morph": { + "stateMap": { + "thinking": "dots", + "orbit": "orbit", + "radar": "radar", + "progress": "progress", + "spawning": "gather", + "dictating": "wave", + "sending": "send", + "receiving": "receive", + "uploading": "dock", + "bouncing": "ball", + "writing": "pencil", + "alerting": "bang", + "powering-down": "standby" + }, + "kindZoom": { + "dots": 1.5, + "orbit": 1.14, + "radar": 1.14, + "progress": 1.32, + "gather": 1.15, + "wave": 1.42, + "send": 1.12, + "receive": 1.12, + "dock": 1.3, + "ball": 1.22, + "pencil": 1.18, + "bang": 1.28, + "standby": 1.75 + }, + "baseRadius": { + "dots": 22, + "orbit": 19, + "radar": 19, + "progress": 19, + "gather": 19, + "wave": 16, + "send": 20, + "receive": 20, + "dock": 20, + "ball": 18, + "pencil": 17, + "bang": 13, + "standby": 13 + }, + "weightsFull": 0.62, + "canvasCenter": 114.2705, + "markOffset": 114.5, + "discR": 22, + "dotsGap": 62, + "dotsStagger": 0.12, + "liftAmp": 9, + "liftPeriodMs": 1400, + "liftBoost": 1.02, + "sendPeriodMs": 1500, + "receivePeriodMs": 1700, + "pencilPeriodMs": 2500, + "pencilDrawFrac": 0.68, + "pencilTrailMs": 1700.0000000000002, + "trailMinStep": 2.4, + "trailSampleMsPerFrame": 120, + "progressShotMs": 2500, + "gatherShotMs": 2000, + "waveOffsets": [ + -2, + -1, + 1, + 2 + ], + "orbitRadPerMs": 0.0017, + "standbyRadPerMs": 0.0016, + "zoomFloor": 0.84, + "zoomFloorAmt": 0.22 + }, + "misc": { + "Ps": 0.92, + "emphasisFace": "surprised", + "maxDtMorph": 0.1 + } + }, + "layout": { + "Fx": 0.26, + "zx": 0.57, + "rs": 0.64, + "pl": 11, + "Dx": 18, + "os": 48, + "ml": 10, + "gl": 64, + "yl": { + "on": false, + "oval": false, + "width": 0, + "height": 0, + "tilt": 0, + "cx": 0, + "cy": 0 + } + } +} \ No newline at end of file diff --git a/src/web-ui/src/flow_chat/components/grok-live/grokLiveSettings.ts b/src/web-ui/src/flow_chat/components/grok-live/grokLiveSettings.ts new file mode 100644 index 0000000000..7278c45f46 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/grok-live/grokLiveSettings.ts @@ -0,0 +1,104 @@ +export interface GrokLiveSettings { + size: number; + fps: 'auto' | '60' | '120'; + follow: boolean; + idleMotion: boolean; + glow: boolean; + showStats: boolean; + bodyColor: 'black' | 'darkGray' | 'gray' | 'custom'; + bodyCustomColor: string; + eyeColor: 'white' | 'lightGray' | 'custom'; + eyeCustomColor: string; +} + +export const DEFAULT_GROK_LIVE_SETTINGS: GrokLiveSettings = { + size: 96, fps: 'auto', follow: true, idleMotion: true, glow: false, showStats: false, + bodyColor: 'black', bodyCustomColor: '#000000', + eyeColor: 'white', eyeCustomColor: '#ffffff', +}; + +const BODY_COLOR_PRESETS: Record = { + black: '#000000', darkGray: '#3a3a3a', gray: '#737373', +}; +const EYE_COLOR_PRESETS: Record = { + white: '#ffffff', lightGray: '#d9d9d9', +}; + +export function resolveGrokBodyColorHex(settings: GrokLiveSettings): string { + if (settings.bodyColor === 'custom') return normalizeHex(settings.bodyCustomColor, '#000000'); + return BODY_COLOR_PRESETS[settings.bodyColor] ?? '#000000'; +} + +export function resolveGrokEyeColorHex(settings: GrokLiveSettings): string { + if (settings.eyeColor === 'custom') return normalizeHex(settings.eyeCustomColor, '#ffffff'); + return EYE_COLOR_PRESETS[settings.eyeColor] ?? '#ffffff'; +} + +function normalizeHex(value: unknown, fallback: string): string { + if (typeof value !== 'string') return fallback; + const v = value.trim(); + return /^#[0-9a-fA-F]{6}$/.test(v) ? v : fallback; +} + +export function hexToRgb01(hex: string): [number, number, number] { + const h = hex.replace('#', ''); + return [ + parseInt(h.slice(0, 2), 16) / 255, + parseInt(h.slice(2, 4), 16) / 255, + parseInt(h.slice(4, 6), 16) / 255, + ]; +} +const KEY = 'openbitfun.grok-live.settings.v1'; +const EVENT = 'openbitfun:grok-live-settings'; +const CHANNEL = 'openbitfun-grok-live-settings'; + +export function readGrokLiveSettings(): GrokLiveSettings { + try { + const saved = JSON.parse(localStorage.getItem(KEY) ?? '{}') as Partial; + return { + size: typeof saved.size === 'number' && Number.isFinite(saved.size) + ? Math.max(64, Math.min(128, Math.round(saved.size))) : 96, + fps: saved.fps === '60' || saved.fps === '120' ? saved.fps : 'auto', + follow: typeof saved.follow === 'boolean' ? saved.follow : true, + idleMotion: typeof saved.idleMotion === 'boolean' ? saved.idleMotion : true, + glow: saved.glow === true, + showStats: saved.showStats === true, + bodyColor: saved.bodyColor === 'darkGray' || saved.bodyColor === 'gray' || saved.bodyColor === 'custom' + ? saved.bodyColor : 'black', + bodyCustomColor: normalizeHex(saved.bodyCustomColor, '#000000'), + eyeColor: saved.eyeColor === 'lightGray' || saved.eyeColor === 'custom' ? saved.eyeColor : 'white', + eyeCustomColor: normalizeHex(saved.eyeCustomColor, '#ffffff'), + }; + } catch { + return { ...DEFAULT_GROK_LIVE_SETTINGS }; + } +} + +export function saveGrokLiveSettings(patch: Partial): GrokLiveSettings { + const next = { ...readGrokLiveSettings(), ...patch }; + localStorage.setItem(KEY, JSON.stringify(next)); + try { + if (typeof BroadcastChannel !== 'undefined') { + const channel = new BroadcastChannel(CHANNEL); + channel.postMessage('changed'); + channel.close(); + } + } catch { /* Cross-window broadcast is optional; persisted settings still load at startup. */ } + window.dispatchEvent(new Event(EVENT)); + return next; +} + +export function subscribeGrokLiveSettings(onChange: () => void): () => void { + const onStorage = (event: StorageEvent) => { if (event.key === KEY) onChange(); }; + window.addEventListener(EVENT, onChange); + window.addEventListener('storage', onStorage); + window.addEventListener('focus', onChange); + const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(CHANNEL); + if (channel) channel.onmessage = onChange; + return () => { + window.removeEventListener(EVENT, onChange); + window.removeEventListener('storage', onStorage); + window.removeEventListener('focus', onChange); + channel?.close(); + }; +} diff --git a/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.scss b/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.scss new file mode 100644 index 0000000000..4b584e17dc --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.scss @@ -0,0 +1,26 @@ +.openbitfun-agent-companion-pet.living-line-pet { + width: 216px; + height: 96px; + flex: 0 0 216px; + overflow: visible; + pointer-events: none; +} + +.living-line-pet__art { + display: block; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; + + path { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; + } + + .state-ring { + fill: none; + } +} diff --git a/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.test.tsx b/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.test.tsx new file mode 100644 index 0000000000..7643d3f8c7 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { LivingLinePet, LIVING_LINE_FRAME_SIZE } from './LivingLinePet'; +import { LivingLineEngine } from './engine'; +import { DEFAULT_SETTINGS, LIVING_LINE_SETTINGS_KEY } from './settings'; +import { saveLivingLineSettings } from './livingLineSettingsStore'; + +describe('LivingLine product settings integration', () => { + let container: HTMLDivElement; + let root: Root; + let nextFrame: FrameRequestCallback; + const requestFrame = vi.fn((callback: FrameRequestCallback) => { nextFrame = callback; return 1; }); + const cancelFrame = vi.fn(); + const onFrame = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('BroadcastChannel', undefined); + vi.stubGlobal('requestAnimationFrame', requestFrame); + vi.stubGlobal('cancelAnimationFrame', cancelFrame); + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }))); + localStorage.clear(); + requestFrame.mockClear(); cancelFrame.mockClear(); onFrame.mockClear(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + localStorage.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('hot-updates colors and widths without remounting the renderer or resetting its state', () => { + const setState = vi.spyOn(LivingLineEngine.prototype, 'setState'); + act(() => root.render()); + const svg = container.querySelector('svg'); + const main = container.querySelector('.main-path')!; + expect(LIVING_LINE_FRAME_SIZE).toEqual({ width: 216, height: 96 }); + expect(svg?.getAttribute('viewBox')).toBe('32 29 192 85.33333333333333'); + const [, , viewWidth, viewHeight] = svg!.getAttribute('viewBox')!.split(' ').map(Number); + expect(LIVING_LINE_FRAME_SIZE.width / viewWidth).toBeCloseTo(288 / 256); + expect(LIVING_LINE_FRAME_SIZE.height / viewHeight).toBeCloseTo(288 / 256); + expect(onFrame).toHaveBeenCalledExactlyOnceWith(LIVING_LINE_FRAME_SIZE); + expect(requestFrame).toHaveBeenCalledTimes(1); + expect(setState).toHaveBeenCalledTimes(1); + + act(() => saveLivingLineSettings({ lineColor: '#123456', lineWidth: 4.5 })); + expect(container.querySelector('svg')).toBe(svg); + expect(container.querySelector('.main-path')).toBe(main); + expect(main.getAttribute('stroke')).toBe('#123456'); + expect(requestFrame).toHaveBeenCalledTimes(1); + expect(setState).toHaveBeenCalledTimes(1); + expect(onFrame).toHaveBeenCalledTimes(1); + act(() => { nextFrame(100); nextFrame(116); }); + expect((main as SVGElement).style.strokeWidth).toBe('4.5px'); + }); + + it('restores defaults after cross-window removal or clear and cleans up on unmount', () => { + saveLivingLineSettings({ lineColor: '#123456' }); + act(() => root.render()); + const svg = container.querySelector('svg'); + localStorage.removeItem(LIVING_LINE_SETTINGS_KEY); + act(() => window.dispatchEvent(new StorageEvent('storage', { key: LIVING_LINE_SETTINGS_KEY, newValue: null }))); + expect(container.querySelector('.main-path')?.getAttribute('stroke')).toBe(DEFAULT_SETTINGS.lineColor); + act(() => saveLivingLineSettings({ lineColor: '#654321' })); + localStorage.clear(); + act(() => window.dispatchEvent(new StorageEvent('storage', { key: null }))); + expect(container.querySelector('svg')).toBe(svg); + expect(container.querySelector('.main-path')?.getAttribute('stroke')).toBe(DEFAULT_SETTINGS.lineColor); + act(() => root.render(null)); + expect(cancelFrame).toHaveBeenCalledWith(1); + expect(onFrame).toHaveBeenLastCalledWith(null); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.tsx b/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.tsx new file mode 100644 index 0000000000..8a906c1aab --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react'; +import type { AgentCompanionPetMood } from '../AgentCompanionPet'; +import { LivingLineRenderer } from './preview'; +import { readLivingLineSettings, subscribeLivingLineSettings } from './livingLineSettingsStore'; +import type { LivingState } from './geometry'; +import './LivingLinePet.scss'; + +export const LIVING_LINE_FRAME_SIZE = { width: 216, height: 96 } as const; + +export function LivingLinePet({ + state, + mood, + className = '', + onFrameSizeChange, +}: { + state: LivingState; + mood: AgentCompanionPetMood; + className?: string; + onFrameSizeChange?: (size: { width: number; height: number } | null) => void; +}) { + const [settings, setSettings] = useState(readLivingLineSettings); + const [reducedMotion, setReducedMotion] = useState(() => + typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches); + + useEffect(() => { + if (typeof window.matchMedia !== 'function') return; + const media = window.matchMedia('(prefers-reduced-motion: reduce)'); + const update = () => setReducedMotion(media.matches); + update(); + media.addEventListener('change', update); + return () => media.removeEventListener('change', update); + }, []); + + useEffect(() => subscribeLivingLineSettings(() => setSettings(readLivingLineSettings())), []); + + useEffect(() => { + onFrameSizeChange?.(LIVING_LINE_FRAME_SIZE); + return () => onFrameSizeChange?.(null); + }, [onFrameSizeChange]); + + return ( + + ); +} diff --git a/src/web-ui/src/flow_chat/components/living-line/appearance.ts b/src/web-ui/src/flow_chat/components/living-line/appearance.ts new file mode 100644 index 0000000000..0f1ec70fb0 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/appearance.ts @@ -0,0 +1,12 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const livingLinePreviewAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'living-line-preview', + parts: [ + { id: 'root' }, + { id: 'stage' }, + { id: 'controls' }, + { id: 'options' }, + { id: 'settings' }, + ], +}; \ No newline at end of file diff --git a/src/web-ui/src/flow_chat/components/living-line/engine.test.ts b/src/web-ui/src/flow_chat/components/living-line/engine.test.ts new file mode 100644 index 0000000000..18e9c3e585 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/engine.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { GEOMETRY, IDLE_WAVE_MODES, idleWaveLines, MARK, MARK_RADIUS, SOURCE_PATHS, toPolyline, TRANSITION_SPRING, workingCoreStrokeWidth, workingHoverLines } from './geometry'; +import { LivingLineEngine } from './engine'; + +describe('LivingLine geometry and engine', () => { + it('uses the approved v0.4 SVG paths and fixed anchors', () => { + expect(SOURCE_PATHS.working).toContain('M60.5,84L76,84L83,80L90,84'); + expect(SOURCE_PATHS.question).toMatch(/^M60.5,84C/); + expect(SOURCE_PATHS.question).toContain('149,53.25'); + expect(SOURCE_PATHS.error).toContain('128,36'); + expect(GEOMETRY.working[0].x).toBe(60.5); + expect(GEOMETRY.working.at(-1)?.x).toBe(195.5); + for (const state of ['working', 'question', 'error'] as const) { + expect(GEOMETRY[state][0]).toEqual({ x: 60.5, y: 84 }); + expect(GEOMETRY[state].at(-1)).toEqual({ x: 195.5, y: 84 }); + } + }); + it('flattens the approved paths to shared M/L vertices while retaining landmarks', () => { + const lengths = new Set(Object.values(GEOMETRY).map(points => points.length)); + expect(lengths.size).toBe(1); + expect(GEOMETRY.working.length).toBeGreaterThan(128); + const d = toPolyline(GEOMETRY.working); + expect(d.match(/[A-Z]/g)).toEqual(['M', ...Array(GEOMETRY.working.length - 1).fill('L')]); + expect(GEOMETRY.working).toContainEqual({ x: 116, y: 58 }); + expect(GEOMETRY.working).toContainEqual({ x: 123, y: 95 }); + }); + it('uses matching hollow-ring sizes and a fixed center from the approved assets', () => { + expect(MARK).toMatchObject({ cx: 128, cy: 106, questionRadius: 4.2, errorRadius: 4.8, strokeWidth: 3.2 }); + expect(MARK_RADIUS).toMatchObject({ idle: 0, working: 0, question: 4.2, error: 4.8 }); + }); + it('generates varied idle waves with fixed anchors and optional second line', () => { + const single = idleWaveLines(GEOMETRY.idle, 0.8, IDLE_WAVE_MODES[0]); + const layered = idleWaveLines(GEOMETRY.idle, 0.8, IDLE_WAVE_MODES[1]); + const split = idleWaveLines(GEOMETRY.idle, 0.8, IDLE_WAVE_MODES[1], true); + expect(single).toHaveLength(1); + expect(layered).toHaveLength(2); + expect(split).toHaveLength(3); + expect(new Set(split.map(toPolyline)).size).toBe(3); + for (const line of [...single, ...layered, ...split]) { + expect(line[0]).toEqual(GEOMETRY.idle[0]); + expect(line.at(-1)).toEqual(GEOMETRY.idle.at(-1)); + } + expect(new Set(IDLE_WAVE_MODES.map(mode => `${mode.cycles}:${mode.amplitude}`)).size).toBeGreaterThan(1); + const sparse = idleWaveLines([{ x: 0, y: 0 }, { x: 50, y: 0 }, { x: 100, y: 0 }], 0.8, IDLE_WAVE_MODES[1])[0]; + const dense = idleWaveLines([{ x: 0, y: 0 }, { x: 12, y: 0 }, { x: 50, y: 0 }, { x: 88, y: 0 }, { x: 100, y: 0 }], 0.8, IDLE_WAVE_MODES[1])[0]; + expect(sparse[1].y).toBeCloseTo(dense[2].y, 8); + }); + it('thickens the working core continuously as split lines merge', () => { + expect(workingCoreStrokeWidth(1)).toBeCloseTo(1.792, 3); + expect(workingCoreStrokeWidth(0.5)).toBeCloseTo(2.496, 3); + expect(workingCoreStrokeWidth(0)).toBe(3.2); + expect(workingCoreStrokeWidth(-1)).toBe(3.2); + expect(workingCoreStrokeWidth(2)).toBeCloseTo(1.792, 3); + }); + it('creates three distinct M/L hover ECGs with matching endpoints', () => { + const paths = workingHoverLines(GEOMETRY.working, 0.8); + expect(paths).toHaveLength(3); + expect(new Set(paths.map(toPolyline)).size).toBe(3); + for (const points of paths) { + expect(points[0]).toEqual(GEOMETRY.working[0]); + expect(points.at(-1)).toEqual(GEOMETRY.working.at(-1)); + expect(toPolyline(points)).not.toMatch(/[CQASTZ]/i); + } + }); + it('uses a slower transition spring while preserving its damped response', () => { + expect(TRANSITION_SPRING).toEqual({ stiffness: 66.7, damping: 16 }); + const engine = new LivingLineEngine(); + engine.setState('working'); + for (let i = 0; i < 30; i++) engine.step(1 / 60); + expect(engine.settled).toBe(false); + for (let i = 0; i < 900; i++) engine.step(1 / 60); + expect(engine.settled).toBe(true); + }); + it('keeps both endpoints fixed during updates', () => { + const engine = new LivingLineEngine(); + const before = engine.getPoints(); + engine.setState('working'); + const after = engine.step(1 / 60); + expect(after[0]).toEqual(before[0]); + expect(after.at(-1)).toEqual(before.at(-1)); + }); + it('eases the hollow dot from invisible center to its approved radius', () => { + const engine = new LivingLineEngine(); + expect(engine.getMarkRadius()).toBe(0); + engine.setState('question'); + engine.step(1 / 60); + expect(engine.getMarkRadius()).toBeGreaterThan(0); + expect(engine.getMarkRadius()).toBeLessThan(MARK.questionRadius); + for (let i = 0; i < 600; i++) engine.step(1 / 60); + expect(engine.getMarkRadius()).toBeCloseTo(MARK.questionRadius, 2); + }); + it('springs the fixed-center ring back to an invisible point on exit', () => { + const engine = new LivingLineEngine(); + engine.setState('error'); + for (let i = 0; i < 240; i++) engine.step(1 / 60); + expect(engine.getMarkRadius()).toBeCloseTo(MARK.errorRadius, 2); + engine.setState('idle'); + expect(engine.getMarkRadius()).toBeGreaterThan(0); + for (let i = 0; i < 240; i++) engine.step(1 / 60); + expect(engine.getMarkRadius()).toBe(0); + }); + it('jumps directly to targets when reduced motion is enabled', () => { + const engine = new LivingLineEngine(); + engine.setReducedMotion(true); + engine.setState('error'); + expect(engine.settled).toBe(true); + expect(engine.getMarkRadius()).toBe(MARK.errorRadius); + expect(engine.step(1 / 60)).toEqual(engine.getPoints()); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/living-line/engine.ts b/src/web-ui/src/flow_chat/components/living-line/engine.ts new file mode 100644 index 0000000000..df6962c56c --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/engine.ts @@ -0,0 +1,78 @@ +import { GEOMETRY, MARK_RADIUS, TRANSITION_SPRING, type LivingState, type Point } from './geometry'; + +type MutablePoint = { x: number; y: number }; + +export class LivingLineEngine { + private current: MutablePoint[]; + private target: MutablePoint[]; + private velocity: MutablePoint[]; + private markRadius = 0; + private markTarget = 0; + private markVelocity = 0; + private state: LivingState = 'idle'; + private reducedMotion = false; + private readonly start: Point; + private readonly end: Point; + + constructor( + private readonly stiffness = TRANSITION_SPRING.stiffness, + private readonly damping = TRANSITION_SPRING.damping, + ) { + this.current = GEOMETRY.idle.map(p => ({ ...p })); + this.target = this.current.map(p => ({ ...p })); + this.velocity = this.current.map(() => ({ x: 0, y: 0 })); + this.start = { ...this.current[0] }; + this.end = { ...this.current[this.current.length - 1] }; + } + + getState(): LivingState { return this.state; } + getPoints(): readonly Point[] { return this.current.map(p => ({ ...p })); } + getMarkRadius(): number { return this.markRadius; } + get settled(): boolean { + return this.markRadius === this.markTarget && this.current.every((p, i) => Math.abs(p.x - this.target[i].x) < 0.05 && Math.abs(p.y - this.target[i].y) < 0.05); + } + + setReducedMotion(enabled: boolean): void { + this.reducedMotion = enabled; + if (enabled) this.snapToTarget(); + } + + setState(state: LivingState): void { + this.state = state; + this.target = GEOMETRY[state].map(p => ({ ...p })); + this.target[0] = { ...this.start }; + this.target[this.target.length - 1] = { ...this.end }; + this.markTarget = MARK_RADIUS[state]; + if (this.reducedMotion) this.snapToTarget(); + } + + step(deltaSeconds: number): readonly Point[] { + if (this.reducedMotion || !this.current.length) return this.getPoints(); + const dt = Math.min(Math.max(deltaSeconds, 0), 1 / 30); + for (let i = 1; i < this.current.length - 1; i++) { + for (const axis of ['x', 'y'] as const) { + const acceleration = this.stiffness * (this.target[i][axis] - this.current[i][axis]) - this.damping * this.velocity[i][axis]; + this.velocity[i][axis] += acceleration * dt; + this.current[i][axis] += this.velocity[i][axis] * dt; + } + } + const markAcceleration = this.stiffness * (this.markTarget - this.markRadius) - this.damping * this.markVelocity; + this.markVelocity += markAcceleration * dt; + const previousRadius = this.markRadius; + this.markRadius = Math.max(0, this.markRadius + this.markVelocity * dt); + if ((previousRadius - this.markTarget) * (this.markRadius - this.markTarget) <= 0 || (Math.abs(this.markRadius - this.markTarget) < 0.01 && Math.abs(this.markVelocity) < 0.02)) { + this.markRadius = this.markTarget; + this.markVelocity = 0; + } + this.current[0] = { ...this.start }; + this.current[this.current.length - 1] = { ...this.end }; + return this.getPoints(); + } + + private snapToTarget(): void { + this.current = this.target.map(p => ({ ...p })); + this.velocity = this.current.map(() => ({ x: 0, y: 0 })); + this.markRadius = this.markTarget; + this.markVelocity = 0; + } +} diff --git a/src/web-ui/src/flow_chat/components/living-line/flow.test.ts b/src/web-ui/src/flow_chat/components/living-line/flow.test.ts new file mode 100644 index 0000000000..3cc1d71e44 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/flow.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { approachFlowOpacity, polylineLength, sliceFlowHighlight } from './flow'; + +const near = (a: number, b: number) => Math.abs(a - b) < 0.001; + +describe('arc-length flow highlight', () => { + it('measures the rendered path rather than its vertex count', () => { + expect(polylineLength([{ x: 0, y: 0 }, { x: 3, y: 4 }, { x: 13, y: 4 }])).toBe(15); + }); + it('moves a fixed-length segment at equal arc distance across unequal edges', () => { + const path = [{ x: 0, y: 0 }, { x: 3, y: 0 }, { x: 3, y: 9 }]; + const first = sliceFlowHighlight(path, 0.2, 2); + const second = sliceFlowHighlight(path, 0.4, 2); + expect(polylineLength(first)).toBeCloseTo(2, 3); + expect(polylineLength(second)).toBeCloseTo(2, 3); + expect(first.at(-1)!.x).toBeGreaterThan(0); + expect(second.at(-1)!.y).toBeGreaterThan(first.at(-1)!.y); + }); + it('leaves the path empty during the full off-path loop gap', () => { + const path = [{ x: 0, y: 0 }, { x: 20, y: 0 }]; + expect(sliceFlowHighlight(path, 0, 5)).toEqual([]); + expect(sliceFlowHighlight(path, 0.9, 5)).toEqual([]); + expect(sliceFlowHighlight(path, 0.5, 5).length).toBeGreaterThan(0); + }); + it('fades streamlines smoothly when the active line count changes', () => { + const entering = approachFlowOpacity(0, true, 1 / 60); + const leaving = approachFlowOpacity(1, false, 1 / 60); + expect(entering).toBeGreaterThan(0); + expect(entering).toBeLessThan(1); + expect(leaving).toBeGreaterThan(0); + expect(leaving).toBeLessThan(1); + expect(approachFlowOpacity(entering, true, 1 / 60)).toBeGreaterThan(entering); + }); + it('returns nothing for degenerate paths and hotspot lengths', () => { + expect(sliceFlowHighlight([{ x: 1, y: 1 }], 0.5, 5)).toEqual([]); + expect(sliceFlowHighlight([{ x: 0, y: 0 }, { x: 0, y: 0 }], 0.5, 5)).toEqual([]); + expect(sliceFlowHighlight([{ x: 0, y: 0 }, { x: 1, y: 0 }], 0.5, 0)).toEqual([]); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/living-line/flow.ts b/src/web-ui/src/flow_chat/components/living-line/flow.ts new file mode 100644 index 0000000000..f67982c2d6 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/flow.ts @@ -0,0 +1,45 @@ +import type { Point } from './geometry'; + +type ArcTable = { points: readonly Point[]; lengths: number[]; total: number }; + +function arcTable(points: readonly Point[]): ArcTable { + const lengths = [0]; + for (let i = 1; i < points.length; i++) lengths.push(lengths[i - 1] + Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y)); + return { points, lengths, total: lengths[lengths.length - 1] }; +} + +function atDistance(table: ArcTable, distance: number): Point { + let i = 1; + while (i < table.lengths.length - 1 && table.lengths[i] < distance) i++; + const span = table.lengths[i] - table.lengths[i - 1]; + const t = span ? (distance - table.lengths[i - 1]) / span : 0; + const a = table.points[i - 1]; + const b = table.points[i]; + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; +} + +export function polylineLength(points: readonly Point[]): number { + return arcTable(points).total; +} + +export function approachFlowOpacity(current: number, active: boolean, deltaSeconds: number, fadeSeconds = 0.32): number { + const amount = 1 - Math.exp(-Math.max(0, deltaSeconds) / Math.max(0.001, fadeSeconds)); + return current + ((active ? 1 : 0) - current) * amount; +} + +/** progress moves the hotspot head through the path, then leaves a full hotspot-length gap before re-entering. */ +export function sliceFlowHighlight(points: readonly Point[], progress: number, hotspotLength: number): readonly Point[] { + const table = arcTable(points); + if (table.total <= 0 || points.length < 2 || hotspotLength <= 0) return []; + const head = ((progress % 1) + 1) % 1 * (table.total + hotspotLength * 2); + if (head <= 0 || head >= table.total + hotspotLength) return []; + const start = Math.max(0, head - hotspotLength); + const end = Math.min(table.total, head); + if (end <= start) return []; + const slice = [atDistance(table, start)]; + for (let i = 1; i < table.lengths.length - 1; i++) { + if (table.lengths[i] > start && table.lengths[i] < end) slice.push(table.points[i]); + } + slice.push(atDistance(table, end)); + return slice; +} diff --git a/src/web-ui/src/flow_chat/components/living-line/geometry.ts b/src/web-ui/src/flow_chat/components/living-line/geometry.ts new file mode 100644 index 0000000000..5e161cf73d --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/geometry.ts @@ -0,0 +1,124 @@ +export type Point = Readonly<{ x: number; y: number }>; +export type LivingState = 'idle' | 'working' | 'question' | 'error'; +type FlatPoint = { x: number; y: number }; + +// Path data is transcribed from the locally approved v0.4 SVG masters; cubic curves are flattened only for animation. +export const SOURCE_PATHS: Record = { + idle: 'M60.5,84L195.5,84', + working: 'M60.5,84L76,84L83,80L90,84L103,84L109,89L116,58L123,95L130,84L148,84L159,79L170,84L195.5,84', + question: 'M60.5,84C81.60009765625,84,102.7001953125,84,123.7998046875,84C123.7998046875,81,123.7998046875,78,123.7998046875,75C123.7998046875,69,127.2998046875,66,132.89990234375,63C138.5,60,141.2998046875,57,141.2998046875,53.25C141.2998046875,47.25,135.7001953125,42.75,128.7001953125,42.75C121,42.75,115.39990234375,47.25,115.39990234375,54C115.39990234375,55.5,115.39990234375,57,115.39990234375,57C113.2998046875,57,110.5,57,108.39990234375,57C108.39990234375,56.25,108.39990234375,54.75,108.39990234375,54C108.39990234375,42.75,116.7998046875,35.25,128.7001953125,35.25C140.60009765625,35.25,149,42.75,149,53.25C149,61.5,144.7998046875,66,137.7998046875,69.75C133.60009765625,72,132.2001953125,74.25,132.2001953125,77.25C132.2001953125,79.5,132.2001953125,81.75,132.2001953125,84C153.2998046875,84,174.39990234375,84,195.5,84', + error: 'M60.5,84C81.40009765625,84,102.3001953125,84,123.2001953125,84C123.2001953125,72,122.2998046875,61,121.7001953125,51C121.10009765625,44,122.2998046875,36,128,36C133.7001953125,36,134.89990234375,44,134.2998046875,51C133.7001953125,61,132.7998046875,72,132.7998046875,84C153.7001953125,84,174.60009765625,84,195.5,84', +}; + +function flattenPath(path: string): FlatPoint[] { + const tokens = path.match(/[MLC]|-?(?:\d+\.?\d*|\.\d+)/g) ?? []; + const points: FlatPoint[] = []; + let i = 0; + while (i < tokens.length) { + const command = tokens[i++]; + if (command === 'M' || command === 'L') { + points.push({ x: Number(tokens[i++]), y: Number(tokens[i++]) }); + continue; + } + if (command !== 'C') throw new Error(`Unsupported LivingLine path command: ${command}`); + const p0 = points[points.length - 1]; + const p1 = { x: Number(tokens[i++]), y: Number(tokens[i++]) }; + const p2 = { x: Number(tokens[i++]), y: Number(tokens[i++]) }; + const p3 = { x: Number(tokens[i++]), y: Number(tokens[i++]) }; + if (!p0) throw new Error('LivingLine cubic segment has no start point'); + for (let step = 1; step <= 12; step++) { + const t = step / 12; + const u = 1 - t; + points.push({ + x: u ** 3 * p0.x + 3 * u ** 2 * t * p1.x + 3 * u * t ** 2 * p2.x + t ** 3 * p3.x, + y: u ** 3 * p0.y + 3 * u ** 2 * t * p1.y + 3 * u * t ** 2 * p2.y + t ** 3 * p3.y, + }); + } + } + return points; +} + +function arcTable(points: readonly Point[]) { + const lengths = [0]; + for (let i = 1; i < points.length; i++) { + lengths.push(lengths[i - 1] + Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y)); + } + return { points, lengths, total: lengths[lengths.length - 1] }; +} + +const tables = Object.fromEntries(Object.entries(SOURCE_PATHS).map(([state, path]) => [state, arcTable(flattenPath(path))])) as Record>; +const landmarks = [...new Set([ + ...Array.from({ length: 129 }, (_, i) => i / 128), + ...Object.values(tables).flatMap(({ lengths, total }) => lengths.map(length => length / total)), +])].sort((a, b) => a - b); + +function atLength(table: ReturnType, fraction: number): Point { + const distance = fraction * table.total; + let i = 1; + while (i < table.lengths.length - 1 && table.lengths[i] < distance) i++; + const span = table.lengths[i] - table.lengths[i - 1]; + const t = span ? (distance - table.lengths[i - 1]) / span : 0; + const a = table.points[i - 1]; + const b = table.points[i]; + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; +} + +export const GEOMETRY: Record = { + idle: landmarks.map(fraction => atLength(tables.idle, fraction)), + working: landmarks.map(fraction => atLength(tables.working, fraction)), + question: landmarks.map(fraction => atLength(tables.question, fraction)), + error: landmarks.map(fraction => atLength(tables.error, fraction)), +}; +export const WIDTH = 256; +export const HEIGHT = 144; +// At the 216px product frame this retains the original 288/256 artwork scale. +// Only the straight side regions shrink; the central glyphs keep their size. +export const VIEW_BOX = '32 29 192 85.33333333333333'; +export const MARK = { cx: 128, cy: 106, questionRadius: 4.2, errorRadius: 4.8, strokeWidth: 3.2 } as const; +export const TRANSITION_SPRING = { stiffness: 66.7, damping: 16 } as const; +export const MARK_RADIUS: Record = { idle: 0, working: 0, question: MARK.questionRadius, error: MARK.errorRadius }; + +export function toPolyline(points: readonly Point[]): string { + return points.map((p, index) => `${index === 0 ? 'M' : 'L'}${p.x.toFixed(3)} ${p.y.toFixed(3)}`).join(' '); +} + +export type IdleWaveMode = Readonly<{ cycles: number; amplitude: number; secondCycles: number; secondAmplitude: number; secondOpacity: number }>; +export const IDLE_WAVE_MODES: readonly IdleWaveMode[] = [ + { cycles: 1.6, amplitude: 10, secondCycles: 0, secondAmplitude: 0, secondOpacity: 0 }, + { cycles: 2.4, amplitude: 12, secondCycles: 1.65, secondAmplitude: 4.8, secondOpacity: 0.24 }, + { cycles: 3, amplitude: 9.5, secondCycles: 2.1, secondAmplitude: 4, secondOpacity: 0.18 }, +]; + +export function idleWaveLines(points: readonly Point[], phase: number, mode: IdleWaveMode, splitProgress = 0): Point[][] { + const startX = points[0]?.x ?? 0; + const spanX = (points.at(-1)?.x ?? startX) - startX || 1; + const makeWave = (cycles: number, amplitude: number, offset: number, speed: number) => points.map(point => { + const u = (point.x - startX) / spanX; + const envelope = Math.sin(Math.PI * u) ** 1.2; + return { x: point.x, y: point.y + envelope * amplitude * Math.sin(u * cycles * Math.PI * 2 + phase * speed + offset) }; + }); + const primary = makeWave(mode.cycles, mode.amplitude, 0, 1); + if (splitProgress > 0) { + return [primary, ...[ + makeWave(mode.cycles * 1.12, mode.amplitude * 0.82, 2.15, -0.82), + makeWave(mode.cycles * 1.43, mode.amplitude * 0.94, 4.1, 0.68), + ].map(target => target.map((point, index) => ({ + x: point.x, + y: primary[index].y + (point.y - primary[index].y) * splitProgress, + })))]; + } + return mode.secondOpacity <= 0.001 ? [primary] : [primary, makeWave(mode.secondCycles, mode.secondAmplitude, 1.3, -0.82)]; +} + +export function workingCoreStrokeWidth(splitProgress: number, lineWidth = 3.2): number { + const split = Math.min(1, Math.max(0, splitProgress)); + return lineWidth * 0.56 + (lineWidth - lineWidth * 0.56) * (1 - split); +} + +export function workingHoverLines(points: readonly Point[], phase: number, split = 1): Point[][] { + return [-1, 0, 1].map((lane, laneIndex) => points.map((point, index) => { + if (index === 0 || index === points.length - 1) return { ...point }; + const wave = Math.sin(index * 1.37 + phase + laneIndex * 2.05); + return { x: point.x, y: point.y + split * (lane * (6.5 + Math.abs(wave) * 2) + wave * 2.4) }; + })); +} diff --git a/src/web-ui/src/flow_chat/components/living-line/livingLineSettingsStore.test.ts b/src/web-ui/src/flow_chat/components/living-line/livingLineSettingsStore.test.ts new file mode 100644 index 0000000000..c5d5c1e4c5 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/livingLineSettingsStore.test.ts @@ -0,0 +1,78 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + LIVING_LINE_SETTINGS_KEY, + readLivingLineSettings, + saveLivingLineSettings, + subscribeLivingLineSettings, +} from './livingLineSettingsStore'; +import { DEFAULT_SETTINGS } from './settings'; + +describe('LivingLine settings store', () => { + beforeEach(() => { + localStorage.clear(); + }); + afterEach(() => { + localStorage.clear(); + }); + + it('reads defaults when storage is empty or corrupt', () => { + expect(readLivingLineSettings()).toEqual(DEFAULT_SETTINGS); + localStorage.setItem(LIVING_LINE_SETTINGS_KEY, '{bad'); + expect(readLivingLineSettings()).toEqual(DEFAULT_SETTINGS); + }); + + it('saves and reads a validated patch', () => { + const next = saveLivingLineSettings({ lineWidth: 5 }); + expect(next.lineWidth).toBe(5); + expect(readLivingLineSettings().lineWidth).toBe(5); + }); + + it('throws instead of falsely succeeding on invalid or unavailable storage', () => { + expect(() => saveLivingLineSettings({ lineWidth: 999 })).toThrow(); + const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('quota'); + }); + expect(() => saveLivingLineSettings({ flowBlur: 2 })).toThrow(); + setItem.mockRestore(); + }); + + it('notifies listeners for same-window custom events', () => { + const listener = vi.fn(); + const unsubscribe = subscribeLivingLineSettings(listener); + saveLivingLineSettings({ flowWidth: 3 }); + expect(listener).toHaveBeenCalled(); + unsubscribe(); + }); + + it('notifies listeners on storage removal and clear', () => { + const listener = vi.fn(); + const unsubscribe = subscribeLivingLineSettings(listener); + localStorage.removeItem(LIVING_LINE_SETTINGS_KEY); + window.dispatchEvent(new StorageEvent('storage', { key: LIVING_LINE_SETTINGS_KEY, newValue: null })); + expect(listener).toHaveBeenCalled(); + listener.mockClear(); + window.dispatchEvent(new StorageEvent('storage', { key: null, newValue: null })); + expect(listener).toHaveBeenCalled(); + unsubscribe(); + }); + + it('notifies listeners on focus', () => { + const listener = vi.fn(); + const unsubscribe = subscribeLivingLineSettings(listener); + window.dispatchEvent(new Event('focus')); + expect(listener).toHaveBeenCalled(); + unsubscribe(); + }); + + it('supports BroadcastChannel cross-window notifications when available', async () => { + if (typeof BroadcastChannel === 'undefined') return; + const listener = vi.fn(); + const unsubscribe = subscribeLivingLineSettings(listener); + const channel = new BroadcastChannel('openbitfun-living-line-settings'); + channel.postMessage('changed'); + await vi.waitFor(() => expect(listener).toHaveBeenCalled()); + channel.close(); + unsubscribe(); + }); +}); \ No newline at end of file diff --git a/src/web-ui/src/flow_chat/components/living-line/livingLineSettingsStore.ts b/src/web-ui/src/flow_chat/components/living-line/livingLineSettingsStore.ts new file mode 100644 index 0000000000..29dc9920ee --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/livingLineSettingsStore.ts @@ -0,0 +1,94 @@ +import { + DEFAULT_SETTINGS, + LIVING_LINE_SETTINGS_KEY, + type LivingLineSettings, + validateSettings, +} from './settings'; + +export { + DEFAULT_SETTINGS, + LIVING_LINE_SETTINGS_KEY, + loadSettings, + saveSettings, + validateSettings, + type LivingLineSettings, +} from './settings'; + +export const LIVING_LINE_SETTINGS_EVENT = 'openbitfun:living-line-settings'; +export const LIVING_LINE_SETTINGS_CHANNEL = 'openbitfun-living-line-settings'; + +function getStorage(): Storage | null { + try { + return typeof window === 'undefined' ? null : window.localStorage; + } catch { + return null; + } +} + +export function readLivingLineSettings(): LivingLineSettings { + const storage = getStorage(); + if (!storage) return { ...DEFAULT_SETTINGS }; + try { + const raw = storage.getItem(LIVING_LINE_SETTINGS_KEY); + if (!raw) return { ...DEFAULT_SETTINGS }; + return validateSettings(JSON.parse(raw)); + } catch { + return { ...DEFAULT_SETTINGS }; + } +} + +export function saveLivingLineSettings(patch: Partial): LivingLineSettings { + const storage = getStorage(); + if (!storage) throw new Error('LivingLine settings storage is unavailable'); + const next = validateSettings({ ...readLivingLineSettings(), ...patch }); + storage.setItem(LIVING_LINE_SETTINGS_KEY, JSON.stringify(next)); + + try { + window.dispatchEvent(new CustomEvent(LIVING_LINE_SETTINGS_EVENT)); + } catch { + /* Event dispatch is best-effort; persisted settings remain authoritative. */ + } + + try { + if (typeof BroadcastChannel !== 'undefined') { + const channel = new BroadcastChannel(LIVING_LINE_SETTINGS_CHANNEL); + channel.postMessage('changed'); + channel.close(); + } + } catch { + /* Cross-window broadcast is optional. */ + } + + return next; +} + +export function subscribeLivingLineSettings(listener: () => void): () => void { + if (typeof window === 'undefined') return () => {}; + + const handleStorage = (event: StorageEvent) => { + if (event.key === null || event.key === LIVING_LINE_SETTINGS_KEY) listener(); + }; + const handleCustom = () => listener(); + const handleFocus = () => listener(); + + window.addEventListener('storage', handleStorage); + window.addEventListener(LIVING_LINE_SETTINGS_EVENT, handleCustom); + window.addEventListener('focus', handleFocus); + + let channel: BroadcastChannel | null = null; + try { + if (typeof BroadcastChannel !== 'undefined') { + channel = new BroadcastChannel(LIVING_LINE_SETTINGS_CHANNEL); + channel.onmessage = handleCustom; + } + } catch { + channel = null; + } + + return () => { + window.removeEventListener('storage', handleStorage); + window.removeEventListener(LIVING_LINE_SETTINGS_EVENT, handleCustom); + window.removeEventListener('focus', handleFocus); + channel?.close(); + }; +} \ No newline at end of file diff --git a/src/web-ui/src/flow_chat/components/living-line/preview-entry.tsx b/src/web-ui/src/flow_chat/components/living-line/preview-entry.tsx new file mode 100644 index 0000000000..5ac3b2e487 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/preview-entry.tsx @@ -0,0 +1,6 @@ +import { createRoot } from 'react-dom/client'; +import LivingLinePreview from './preview'; + +const host = document.getElementById('living-line-root'); +if (!host) throw new Error('LivingLine preview root element is missing'); +createRoot(host).render(); diff --git a/src/web-ui/src/flow_chat/components/living-line/preview.html b/src/web-ui/src/flow_chat/components/living-line/preview.html new file mode 100644 index 0000000000..13322e513f --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/preview.html @@ -0,0 +1,14 @@ + + + + + + + + LivingLine · 独立预览 + + +
+ + + diff --git a/src/web-ui/src/flow_chat/components/living-line/preview.scss b/src/web-ui/src/flow_chat/components/living-line/preview.scss new file mode 100644 index 0000000000..1cf9e1589d --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/preview.scss @@ -0,0 +1,26 @@ +.living-line-preview { + min-height: 100vh; padding: 8vh 8vw; box-sizing: border-box; color: #f3f5f3; + background: radial-gradient(ellipse at 50% 42%, #202d31 0%, #111719 52%, #0c1012 100%); + font: 14px/1.5 Inter, ui-sans-serif, system-ui, sans-serif; + header { display: flex; align-items: center; gap: 16px; max-width: 900px; margin: auto; } + header p, .living-line-caption { color: #91a4a3; font-size: 10px; letter-spacing: .18em; } + h1 { margin: 0; font-size: 28px; font-weight: 500; letter-spacing: -.04em; } + header p { margin: 0 0 3px; } + .living-line-mark { display: grid; place-items: center; width: 42px; height: 42px; border: 1px solid #597374; border-radius: 50%; color: #8fe0d4; } + .living-line-stage { max-width: 900px; min-height: 300px; margin: 8vh auto 32px; padding: 40px; display: grid; place-items: center; border: 1px solid #ffffff15; border-radius: 16px; background: #ffffff06; } + .living-line-art { width: min(100%, 600px); overflow: visible; path { fill: none; stroke-width: 3.2; stroke-linecap: round; stroke-linejoin: round; filter: drop-shadow(0 0 7px currentColor); } .state-ring { fill: none; stroke-width: 3.2; filter: drop-shadow(0 0 4px currentColor) drop-shadow(0 0 9px currentColor); } .state-glow { filter: blur(5px); } } + .is-hovering .main-path { visibility: hidden; } + .is-hovering .hover-path:first-of-type, .is-hovering .hover-path:last-of-type { opacity: .4; } + .living-line-caption { margin-top: 12px; } + .living-line-controls, .living-line-options { max-width: 900px; margin: 20px auto; display: flex; justify-content: center; gap: 10px; } + button { padding: 9px 20px; border: 1px solid #ffffff24; border-radius: 999px; background: #ffffff08; color: inherit; text-transform: capitalize; cursor: pointer; &:hover, &[aria-pressed='true'] { border-color: #80d8cd; color: #9de7dd; background: #80d8cd12; } } + .living-line-options { color: #a9b6b5; gap: 28px; label { cursor: pointer; } input { accent-color: #80d8cd; vertical-align: middle; } } + .living-line-settings { max-width: 900px; margin: 24px auto; padding: 16px 20px; border: 1px solid #ffffff20; border-radius: 12px; background: #ffffff06; color: #d9e3e1; } + .living-line-settings summary { cursor: pointer; color: #a9d8d2; } + .settings-colors { display: flex; flex-wrap: wrap; gap: 16px; margin: 18px 0; label { display: flex; align-items: center; gap: 8px; color: #a9b6b5; } input { width: 36px; height: 28px; border: 0; padding: 0; background: transparent; } } + .settings-ranges { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px 24px; label { display: grid; gap: 4px; color: #a9b6b5; } label span { display: flex; justify-content: space-between; } output { color: #d9e3e1; font-variant-numeric: tabular-nums; } input { width: 100%; accent-color: #80d8cd; } } + .settings-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; color: #91a4a3; font-size: 12px; } + footer { margin: 8vh auto 0; max-width: 900px; text-align: center; color: #586867; font-size: 12px; } +} +@media (max-width: 600px) { .living-line-preview { padding: 6vh 5vw; .living-line-stage { padding: 16px; } .living-line-options { flex-wrap: wrap; } } } +@media (prefers-reduced-motion: reduce) { .living-line-art { transition: none; } } diff --git a/src/web-ui/src/flow_chat/components/living-line/preview.tsx b/src/web-ui/src/flow_chat/components/living-line/preview.tsx new file mode 100644 index 0000000000..2e92f7f165 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/preview.tsx @@ -0,0 +1,213 @@ +import { useEffect, useRef, useState } from 'react'; +import { IDLE_WAVE_MODES, MARK, idleWaveLines, toPolyline, VIEW_BOX, workingCoreStrokeWidth, workingHoverLines, type LivingState, type Point } from './geometry'; +import { approachFlowOpacity, sliceFlowHighlight } from './flow'; +import { DEFAULT_SETTINGS, validateSettings, type LivingLineSettings } from './settings'; +import { readLivingLineSettings, saveLivingLineSettings, subscribeLivingLineSettings } from './livingLineSettingsStore'; +import { LivingLineEngine } from './engine'; +import './preview.scss'; + +type RangeKey = Exclude; +const RANGE_CONTROLS: readonly { key: RangeKey; label: string; min: number; max: number; step: number }[] = [ + { key: 'lineWidth', label: 'Main line width', min: 1, max: 8, step: 0.1 }, + { key: 'flowLength', label: 'Flow length', min: 6, max: 64, step: 1 }, + { key: 'flowWidth', label: 'Flow core width', min: 0.8, max: 6, step: 0.1 }, + { key: 'flowBlur', label: 'Flow blur', min: 0, max: 16, step: 0.5 }, + { key: 'flowIntensity', label: 'Flow intensity', min: 0, max: 1, step: 0.05 }, + { key: 'lineGlowBlur', label: 'Ambient glow', min: 0, max: 24, step: 1 }, + { key: 'idlePeriodSeconds', label: 'Idle flow period (seconds)', min: 2, max: 24, step: 0.5 }, + { key: 'workingPeriodSeconds', label: 'Working flow period (seconds)', min: 0.5, max: 10, step: 0.1 }, +]; + +function rgba(hex: string, opacity: number): string { + const value = Number.parseInt(hex.slice(1), 16); + return `rgba(${value >> 16}, ${(value >> 8) & 255}, ${value & 255}, ${opacity})`; +} + +export function LivingLineRenderer({ state, reducedMotion, settings, hovered = false, className = '' }: { state: LivingState; reducedMotion: boolean; settings: LivingLineSettings; hovered?: boolean; className?: string }) { + const engine = useRef(null); + if (!engine.current) engine.current = new LivingLineEngine(); + const main = useRef(null); + const hoverPaths = useRef<(SVGPathElement | null)[]>([]); + const flowPaths = useRef<(SVGPathElement | null)[]>([]); + const ring = useRef(null); + const glow = useRef(null); + const options = useRef({ state, hoverEnabled: false, reducedMotion, settings }); + const [pointerOver, setPointerOver] = useState(false); + const hoverProgress = useRef(0); + const idleSplitProgress = useRef(0); + const hoverTarget = useRef(false); + const waveMode = useRef(0); + const waveFrom = useRef(0); + const waveTransitionStart = useRef(0); + const nextWaveChange = useRef(0); + const flowProgress = useRef(0); + const flowSpeed = useRef(0); + const flowOpacity = useRef([0, 0, 0]); + options.current = { state, hoverEnabled: hovered || pointerOver, reducedMotion, settings }; + hoverTarget.current = pointerOver && !reducedMotion; + useEffect(() => { engine.current!.setState(state); }, [state]); + useEffect(() => { engine.current!.setReducedMotion(reducedMotion); }, [reducedMotion]); + useEffect(() => { + let frame = 0; + let previous = 0; + let phase = 0; + let visible = !document.hidden; + const render = (now: number) => { + if (visible) { + if (previous) { + const delta = Math.min((now - previous) / 1000, 1 / 30); + const points = engine.current!.step(delta); + const d = toPolyline(points); + const config = options.current.settings; + const currentState = options.current.state; + const activeColor = currentState === 'question' ? config.questionColor : currentState === 'error' ? config.errorColor : config.lineColor; + main.current?.setAttribute('stroke', activeColor); + const isAlertState = currentState === 'question' || currentState === 'error'; + const lineGlowBlur = isAlertState ? Math.min(config.lineGlowBlur, 8) : config.lineGlowBlur; + const lineGlowOpacity = isAlertState ? 0.7 : 0.58; + main.current?.style.setProperty('filter', lineGlowBlur ? `drop-shadow(0 0 ${lineGlowBlur}px ${rgba(activeColor, lineGlowOpacity)})` : 'none'); + const dt = delta; + hoverTarget.current = options.current.hoverEnabled && !options.current.reducedMotion; + const splitTarget = currentState === 'working' && hoverTarget.current ? 1 : 0; + hoverProgress.current += (splitTarget - hoverProgress.current) * (1 - Math.exp(-dt / 0.24)); + const idleSplitTarget = currentState === 'idle' && hoverTarget.current ? 1 : 0; + idleSplitProgress.current += (idleSplitTarget - idleSplitProgress.current) * (1 - Math.exp(-dt / 0.28)); + if (!options.current.reducedMotion && currentState === 'idle' && now >= nextWaveChange.current) { + waveFrom.current = waveMode.current; + waveMode.current = (waveMode.current + 1 + Math.floor(Math.random() * (IDLE_WAVE_MODES.length - 1))) % IDLE_WAVE_MODES.length; + waveTransitionStart.current = now; + nextWaveChange.current = now + 4200 + Math.random() * 3800; + } + let lanes: Point[][] = []; + if (currentState === 'working') lanes = workingHoverLines(points, phase, hoverProgress.current); + if (currentState === 'idle') { + const blend = Math.min(1, Math.max(0, (now - waveTransitionStart.current) / 1000)); + const eased = blend * blend * (3 - 2 * blend); + const from = IDLE_WAVE_MODES[waveFrom.current]; + const to = IDLE_WAVE_MODES[waveMode.current]; + const mode = { + cycles: from.cycles + (to.cycles - from.cycles) * eased, + amplitude: from.amplitude + (to.amplitude - from.amplitude) * eased, + secondCycles: from.secondCycles + (to.secondCycles - from.secondCycles) * eased, + secondAmplitude: from.secondAmplitude + (to.secondAmplitude - from.secondAmplitude) * eased, + secondOpacity: from.secondOpacity + (to.secondOpacity - from.secondOpacity) * eased, + }; + lanes = idleWaveLines(points, phase, mode, idleSplitProgress.current); + main.current?.setAttribute('d', toPolyline(lanes[0])); + if (idleSplitProgress.current > 0.01) { + lanes.slice(1).forEach((line, index) => { + const node = hoverPaths.current[index]; + node?.setAttribute('d', toPolyline(line)); node?.setAttribute('visibility', 'visible'); + node?.setAttribute('stroke', activeColor); node?.style.setProperty('stroke-width', `${config.lineWidth * 0.56}px`); + node?.setAttribute('opacity', String(idleSplitProgress.current * 0.72)); + }); + hoverPaths.current.slice(2).forEach(node => node?.setAttribute('visibility', 'hidden')); + } else { + const second = lanes[1]; + hoverPaths.current[0]?.setAttribute('visibility', second ? 'visible' : 'hidden'); + if (second) hoverPaths.current[0]?.setAttribute('d', toPolyline(second)); + hoverPaths.current[0]?.setAttribute('stroke', activeColor); + hoverPaths.current[0]?.style.setProperty('stroke-width', `${config.lineWidth}px`); + hoverPaths.current[0]?.setAttribute('opacity', String(mode.secondOpacity)); + hoverPaths.current.slice(1).forEach(node => node?.setAttribute('visibility', 'hidden')); + } + main.current?.setAttribute('visibility', 'visible'); + } else { + main.current?.setAttribute('d', d); + const coreWidth = workingCoreStrokeWidth(hoverProgress.current, config.lineWidth); + main.current?.style.setProperty('stroke-width', `${coreWidth}px`); + lanes.forEach((line, index) => { + const node = hoverPaths.current[index]; + node?.setAttribute('d', toPolyline(line)); node?.setAttribute('stroke', activeColor); + node?.setAttribute('visibility', currentState === 'working' && hoverProgress.current > 0.001 ? 'visible' : 'hidden'); + node?.style.setProperty('stroke-width', index === 1 ? `${coreWidth}px` : `${config.lineWidth * 0.56}px`); + node?.setAttribute('opacity', String(index === 1 ? 1 : 0.5 * hoverProgress.current)); + }); + hoverPaths.current.forEach((node, index) => { if (index >= lanes.length) node?.setAttribute('visibility', 'hidden'); }); + } + main.current?.setAttribute('visibility', currentState === 'working' && hoverProgress.current > 0.001 ? 'hidden' : 'visible'); + main.current?.style.setProperty('stroke-width', `${currentState === 'working' ? workingCoreStrokeWidth(hoverProgress.current, config.lineWidth) : config.lineWidth}px`); + const radius = engine.current!.getMarkRadius(); + ring.current?.setAttribute('stroke', activeColor); ring.current?.style.setProperty('stroke-width', `${config.lineWidth}px`); + ring.current?.setAttribute('r', String(radius)); ring.current?.setAttribute('visibility', radius < 0.15 ? 'hidden' : 'visible'); + glow.current?.setAttribute('fill', activeColor); + const glowScale = currentState === 'question' || currentState === 'error' ? 1.45 : 1.8; + const glowPeak = currentState === 'question' || currentState === 'error' ? 0.3 : 0.22; + const glowBreathing = isAlertState ? 0.9 + Math.sin(now / 520) * 0.1 : 1; + glow.current?.setAttribute('r', String(radius * glowScale * glowBreathing)); + glow.current?.setAttribute('opacity', String(Math.min(glowPeak, radius / MARK.errorRadius * glowPeak) * glowBreathing)); + + const flowTargets = currentState === 'idle' + ? idleSplitProgress.current > 0.01 ? lanes : lanes.slice(0, IDLE_WAVE_MODES[waveMode.current].secondOpacity > 0.001 ? 2 : 1) + : currentState === 'working' && hoverProgress.current > 0.01 ? lanes : [points]; + const targetSpeed = 1 / (currentState === 'working' ? config.workingPeriodSeconds : currentState === 'question' || currentState === 'error' ? Math.max(config.idlePeriodSeconds * 0.7, 3) : config.idlePeriodSeconds); + flowSpeed.current += (targetSpeed - flowSpeed.current) * (1 - Math.exp(-dt / 0.3)); + if (!options.current.reducedMotion) flowProgress.current += dt * flowSpeed.current; + flowPaths.current.forEach((node, index) => { + const target = flowTargets[index]; + const progress = (flowProgress.current + index * 0.14) % 1; + const segment = target ? sliceFlowHighlight(target, progress, config.flowLength) : []; + const targetActive = Boolean(segment.length && config.flowIntensity > 0 && !options.current.reducedMotion); + flowOpacity.current[index] = approachFlowOpacity(flowOpacity.current[index], targetActive, dt); + if (!node) return; + if (segment.length) node.setAttribute('d', toPolyline(segment)); + node.setAttribute('visibility', flowOpacity.current[index] > 0.005 ? 'visible' : 'hidden'); + node.setAttribute('stroke', config.flowColor); node.style.setProperty('stroke-width', `${config.flowWidth}px`); + node.setAttribute('opacity', String(config.flowIntensity * flowOpacity.current[index])); + node.style.setProperty('filter', config.flowBlur ? `drop-shadow(0 0 ${config.flowBlur}px ${rgba(config.flowColor, config.flowIntensity * flowOpacity.current[index])})` : 'none'); + }); + if (!options.current.reducedMotion) phase += (now - previous) / 1000 * 2.4; + } + previous = now; + } + frame = requestAnimationFrame(render); + }; + const onVisibility = () => { visible = !document.hidden; previous = 0; }; + document.addEventListener('visibilitychange', onVisibility); + frame = requestAnimationFrame(render); + return () => { cancelAnimationFrame(frame); document.removeEventListener('visibilitychange', onVisibility); }; + }, []); + const stroke = state === 'question' ? settings.questionColor : state === 'error' ? settings.errorColor : settings.lineColor; + const initialD = toPolyline(engine.current.getPoints()); + return { if (event.pointerType === 'mouse') setPointerOver(true); }} onMouseEnter={() => setPointerOver(true)} onPointerLeave={() => setPointerOver(false)} onMouseLeave={() => setPointerOver(false)}> + + {Array.from({ length: 3 }, (_, index) => { hoverPaths.current[index] = node; }} className="hover-path" d={initialD} stroke={stroke} visibility="hidden" />)} + {Array.from({ length: 3 }, (_, index) => { flowPaths.current[index] = node; }} className="flow-path" visibility="hidden" />)} + + + ; +} + +export default function LivingLinePreview() { + const [settings, setSettings] = useState(readLivingLineSettings); + const [settingsStatus, setSettingsStatus] = useState('Using local settings'); + const [state, setState] = useState('idle'); + const [reducedMotion, setReducedMotion] = useState(false); + useEffect(() => subscribeLivingLineSettings(() => { + setSettings(readLivingLineSettings()); + setSettingsStatus('Local settings synchronized'); + }), []); + const update = (key: keyof LivingLineSettings, value: string | number) => { + try { + const next = validateSettings({ ...settings, [key]: value }); + setSettings(saveLivingLineSettings(next)); + setSettingsStatus('Saved'); + } catch (error) { setSettingsStatus(error instanceof Error ? error.message : 'Invalid settings'); } + }; + const reset = () => { + try { + setSettings(saveLivingLineSettings({ ...DEFAULT_SETTINGS })); + setSettingsStatus('Defaults restored'); + } catch { setSettingsStatus('Save failed: local storage is unavailable'); } + }; + return
LL

INDEPENDENT MOTION STUDY

LivingLine

+
{state.toUpperCase()} / VECTOR STATE
+ +
Move the pointer over the canvas to preview the split animation
+
Flow and line preview settings +
{([['lineColor', 'Idle/Working line color'], ['questionColor', 'Question color'], ['errorColor', 'Error color'], ['flowColor', 'Flow color']] as const).map(([key, label]) => )}
+
{RANGE_CONTROLS.map(item => )}
+
{settingsStatus}
+
+
LivingLine · Original SVG state and damping engine
; +} diff --git a/src/web-ui/src/flow_chat/components/living-line/settings.test.ts b/src/web-ui/src/flow_chat/components/living-line/settings.test.ts new file mode 100644 index 0000000000..eadad3efd9 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/settings.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_SETTINGS, LIVING_LINE_SETTINGS_KEY, loadSettings, saveSettings, validateSettings } from './settings'; + +describe('LivingLine preview settings', () => { + it('uses an isolated versioned storage key and defaults', () => { + expect(LIVING_LINE_SETTINGS_KEY).toBe('openbitfun.living-line.settings.v1'); + expect(validateSettings({})).toEqual(DEFAULT_SETTINGS); + }); + it('rejects non-finite, out-of-range and invalid color values', () => { + expect(() => validateSettings({ flowBlur: Number.NaN })).toThrow(); + expect(() => validateSettings({ idlePeriodSeconds: 0 })).toThrow(); + expect(() => validateSettings({ lineColor: 'red' })).toThrow(); + expect(() => validateSettings({ extra: 1 })).toThrow(); + }); + it('round-trips validated settings and does not persist invalid values', () => { + const values = new Map(); + const storage = { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); } }; + const candidate = { ...DEFAULT_SETTINGS, flowIntensity: 0.55, flowColor: '#FFFFFF' }; + expect(saveSettings(storage, candidate)).toBe(true); + expect(loadSettings(storage)).toEqual({ settings: candidate, status: 'loaded' }); + expect(saveSettings(storage, { ...candidate, flowLength: Infinity })).toBe(false); + expect(loadSettings(storage).settings).toEqual(candidate); + }); + it('falls back clearly for corrupt persisted data and unavailable storage', () => { + expect(loadSettings({ getItem: () => '{bad', setItem: () => {} })).toEqual({ settings: DEFAULT_SETTINGS, status: 'invalid' }); + expect(loadSettings({ getItem: () => { throw new Error('denied'); }, setItem: () => {} }).status).toBe('unavailable'); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/living-line/settings.ts b/src/web-ui/src/flow_chat/components/living-line/settings.ts new file mode 100644 index 0000000000..ea5e53d6c2 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/settings.ts @@ -0,0 +1,63 @@ +export const LIVING_LINE_SETTINGS_KEY = 'openbitfun.living-line.settings.v1'; + +export type LivingLineSettings = { + lineColor: string; + questionColor: string; + errorColor: string; + flowColor: string; + lineWidth: number; + flowLength: number; + flowWidth: number; + flowBlur: number; + flowIntensity: number; + lineGlowBlur: number; + idlePeriodSeconds: number; + workingPeriodSeconds: number; +}; + +type StorageLike = Pick; +export const DEFAULT_SETTINGS: Readonly = { + lineColor: '#9FE8FF', questionColor: '#FFD45A', errorColor: '#FF8D9A', flowColor: '#FFF8DC', + lineWidth: 3.2, flowLength: 24, flowWidth: 2.4, flowBlur: 4, flowIntensity: 0.9, + lineGlowBlur: 7, idlePeriodSeconds: 8, workingPeriodSeconds: 1.8, +}; + +export const RANGES: Record, readonly [number, number]> = { + lineWidth: [1, 8], flowLength: [6, 64], flowWidth: [0.8, 6], flowBlur: [0, 16], + flowIntensity: [0, 1], lineGlowBlur: [0, 24], idlePeriodSeconds: [2, 24], workingPeriodSeconds: [0.5, 10], +}; + +export function validateSettings(value: unknown): LivingLineSettings { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Settings must be an object'); + const input = value as Record; + const allowed = new Set([...Object.keys(DEFAULT_SETTINGS)]); + if (Object.keys(input).some(key => !allowed.has(key))) throw new Error('Settings contain an unknown field'); + const result = { ...DEFAULT_SETTINGS, ...input } as LivingLineSettings; + for (const key of ['lineColor', 'questionColor', 'errorColor', 'flowColor'] as const) { + if (typeof result[key] !== 'string' || !/^#[\da-f]{6}$/i.test(result[key])) throw new Error(`Invalid color: ${key}`); + } + for (const [key, [min, max]] of Object.entries(RANGES) as [keyof typeof RANGES, readonly [number, number]][]) { + const number = result[key]; + if (typeof number !== 'number' || !Number.isFinite(number) || number < min || number > max) throw new Error(`Value is out of range: ${key}`); + } + return result; +} + +export function loadSettings(storage: StorageLike): { settings: LivingLineSettings; status: 'default' | 'loaded' | 'invalid' | 'unavailable' } { + let raw: string | null; + try { raw = storage.getItem(LIVING_LINE_SETTINGS_KEY); } + catch { return { settings: { ...DEFAULT_SETTINGS }, status: 'unavailable' }; } + if (!raw) return { settings: { ...DEFAULT_SETTINGS }, status: 'default' }; + try { return { settings: validateSettings(JSON.parse(raw)), status: 'loaded' }; } + catch { return { settings: { ...DEFAULT_SETTINGS }, status: 'invalid' }; } +} + +export function saveSettings(storage: StorageLike, value: unknown): boolean { + try { + const validated = validateSettings(value); + storage.setItem(LIVING_LINE_SETTINGS_KEY, JSON.stringify(validated)); + return true; + } catch { + return false; + } +} diff --git a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts index d1151f5e53..66c4fdd255 100644 --- a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts +++ b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts @@ -3,7 +3,7 @@ import { flowChatStore } from '../store/FlowChatStore'; import { stateMachineManager } from '../state-machine/SessionStateMachineManager'; import { ProcessingPhase, SessionExecutionEvent, SessionExecutionState } from '../state-machine/types'; import type { DialogTurn, Session } from '../types/flow-chat'; -import { buildAgentCompanionActivity } from './agentCompanionActivity'; +import { buildAgentCompanionActivity, deriveLivingLineState } from './agentCompanionActivity'; const background = vi.hoisted(() => ({ rows: [] as unknown[] })); vi.mock('../services/crossDeviceSessions', () => ({ @@ -195,6 +195,18 @@ function createAskUserQuestionSessionWithTrailingText(): Session { return session; } +describe('LivingLine activity adapter', () => { + const base = { title: 'Session', mood: 'rest' as const, labelKey: '', defaultLabel: '', startedAt: 0, updatedAt: 0 }; + const status = (sessionId: string, state: 'running' | 'waiting' | 'attention' | 'completed' | 'error' | 'interrupted') => ({ ...base, sessionId, state }); + + it('prioritizes errors across all tasks, then questions, active work, and idle', () => { + expect(deriveLivingLineState([status('a', 'running'), status('b', 'attention')])).toBe('question'); + expect(deriveLivingLineState([status('a', 'completed'), status('b', 'running')])).toBe('working'); + expect(deriveLivingLineState([status('a', 'completed')])).toBe('idle'); + expect(deriveLivingLineState([...Array.from({ length: 4 }, (_, index) => status(String(index), 'completed')), status('late-error', 'error')])).toBe('error'); + }); +}); + describe('buildAgentCompanionActivity', () => { afterEach(() => { resetState(); @@ -230,6 +242,7 @@ describe('buildAgentCompanionActivity', () => { expect(buildAgentCompanionActivity()).toEqual({ mood: 'rest', tasks: [], + livingLineState: 'idle', }); }); diff --git a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts index 174614764c..374ac61c71 100644 --- a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts +++ b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts @@ -53,9 +53,13 @@ export interface AgentCompanionTaskStatus { deviceName?: string; } +export type LivingLineActivityState = 'idle' | 'working' | 'question' | 'error'; + export interface AgentCompanionActivityPayload { mood: AgentCompanionMood; tasks: AgentCompanionTaskStatus[]; + /** Persistent semantic state; independent of transient mood and bubble visibility. */ + livingLineState?: LivingLineActivityState; sequence?: number; emittedAt?: number; } @@ -63,6 +67,7 @@ export interface AgentCompanionActivityPayload { const EMPTY_ACTIVITY: AgentCompanionActivityPayload = { mood: 'rest', tasks: [], + livingLineState: 'idle', }; const taskOrderBySessionId = new Map(); @@ -355,6 +360,13 @@ function taskStableOrder(task: AgentCompanionTaskStatus): number { return ensureTaskOrder(task.sessionId); } +export function deriveLivingLineState(tasks: readonly AgentCompanionTaskStatus[]): LivingLineActivityState { + if (tasks.some(task => task.state === 'error')) return 'error'; + if (tasks.some(task => task.state === 'attention')) return 'question'; + if (tasks.some(task => task.state === 'running' || task.state === 'waiting')) return 'working'; + return 'idle'; +} + function aggregateMood(tasks: AgentCompanionTaskStatus[]): AgentCompanionMood { if (tasks.some(task => task.mood === 'waiting')) { return 'waiting'; @@ -490,6 +502,7 @@ export function buildAgentCompanionActivity(): AgentCompanionActivityPayload { return { mood: aggregateMood(sortedTasks), tasks: sortedTasks, + livingLineState: deriveLivingLineState(tasks), }; } diff --git a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts index 1f9ff26dfb..7f2525e831 100644 --- a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts +++ b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts @@ -135,6 +135,7 @@ import { import { settingsNavAppearanceDescriptor } from '@/app/scenes/settings/SettingsNav.appearance'; import { backgroundCommandOutputPanelAppearanceDescriptor } from '@/flow_chat/components/background-command/BackgroundCommandOutputPanel.appearance'; import { agentCompanionPetAppearanceDescriptor } from '@/flow_chat/components/AgentCompanionPet.appearance'; +import { livingLinePreviewAppearanceDescriptor } from '@/flow_chat/components/living-line/appearance'; import { chatContextPickerAppearanceDescriptor, legacyFileMentionPickerAppearanceDescriptor, @@ -409,6 +410,7 @@ export function createDefaultAppearanceRegistry(): AppearanceRegistry { .registerComponent(automationSettingsPageAppearanceDescriptor) .registerComponent(backgroundCommandOutputPanelAppearanceDescriptor) .registerComponent(agentCompanionPetAppearanceDescriptor) +.registerComponent(livingLinePreviewAppearanceDescriptor) .registerComponent(chatContextPickerAppearanceDescriptor) .registerComponent(legacyFileMentionPickerAppearanceDescriptor) .registerComponent(sessionFileModificationsBarAppearanceDescriptor) diff --git a/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.test.ts b/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.test.ts index f7a2cd636d..37cfb57d71 100644 --- a/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.test.ts +++ b/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.test.ts @@ -24,6 +24,7 @@ describe('AgentCompanionPetService built-in presets', () => { const pets = await listAgentCompanionPets(); const blueGolden = pets.find(pet => pet.id === 'blue-golden'); const openbitfun = pets.find(pet => pet.id === 'openbitfun'); + const livingLine = pets.find(pet => pet.id === 'living-line'); expect(DEFAULT_AGENT_COMPANION_PET).toMatchObject({ id: 'bitblob', @@ -55,6 +56,12 @@ describe('AgentCompanionPetService built-in presets', () => { packagePath: '/agent-companion-pets/openbitfun', spritesheetPath: '/agent-companion-pets/openbitfun/spritesheet.webp', }); + expect(livingLine).toMatchObject({ + displayName: 'LivingLine', + packagePath: '/agent-companion-pets/living-line', + spritesheetPath: '/agent-companion-pets/living-line/preview.svg', + previewSrc: '/agent-companion-pets/living-line/preview.svg', + }); expect(invoke).not.toHaveBeenCalled(); }); diff --git a/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts b/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts index 4d5fe62f85..8e9c925338 100644 --- a/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts +++ b/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts @@ -5,6 +5,7 @@ import { isTauriRuntime } from '@/infrastructure/runtime'; import { createLogger } from '@/shared/utils/logger'; import builtinPetMetadata from './agentCompanionBuiltinPetMetadata.json'; import { getPetSpriteLayout } from './agentCompanionPetSprite'; +import { LIVING_LINE_PET } from './livingLinePetIdentity'; const log = createLogger('AgentCompanionPetService'); const BUILTIN_PET_BASE = '/agent-companion-pets'; @@ -23,6 +24,17 @@ export const DEFAULT_AGENT_COMPANION_PET: AgentCompanionPetSelection = { spriteVersionNumber: 2, }; +export const GROK_LIVE_PET: AgentCompanionPetSelection = { + id: 'grok-live', + displayName: 'GrokLive', + description: 'A live procedural SVG companion with a black body and white eyes.', + source: 'preset', + packagePath: `${BUILTIN_PET_BASE}/grok-live`, + // Only a picker thumbnail: the live renderer never parses this as a spritesheet. + spritesheetPath: `${BUILTIN_PET_BASE}/grok-live/preview.svg`, + spritesheetMimeType: 'image/svg+xml', +}; + /** Cache: absolute file path → blob URL (prevents re-reading the same file). */ const blobUrlCache = new Map(); const blobLoads = new Map; released: boolean }>(); @@ -218,6 +230,8 @@ const BUILTIN_PETS: AgentCompanionPetSelection[] = [ spritesheetPath: `${BUILTIN_PET_BASE}/usagi/spritesheet.webp`, spritesheetMimeType: 'image/webp', }, + GROK_LIVE_PET, + LIVING_LINE_PET, ]; export interface AgentCompanionPetPackage extends AgentCompanionPetSelection { diff --git a/src/web-ui/src/infrastructure/config/services/grokLivePetIdentity.ts b/src/web-ui/src/infrastructure/config/services/grokLivePetIdentity.ts new file mode 100644 index 0000000000..5479ccc78d --- /dev/null +++ b/src/web-ui/src/infrastructure/config/services/grokLivePetIdentity.ts @@ -0,0 +1,6 @@ +import type { AgentCompanionPetSelection } from './AIExperienceConfigService'; + +export function isGrokLivePet(pet: AgentCompanionPetSelection | null | undefined): boolean { + return pet?.source === 'preset' && pet.id === 'grok-live' + && pet.packagePath === '/agent-companion-pets/grok-live'; +} diff --git a/src/web-ui/src/infrastructure/config/services/livingLinePetIdentity.test.ts b/src/web-ui/src/infrastructure/config/services/livingLinePetIdentity.test.ts new file mode 100644 index 0000000000..b8b4e0e2f9 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/services/livingLinePetIdentity.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; +import { LIVING_LINE_PET, isLivingLinePet } from './livingLinePetIdentity'; + +describe('LivingLine pet identity', () => { + it('recognizes only the bundled preset identity', () => { + expect(isLivingLinePet(LIVING_LINE_PET)).toBe(true); + expect(isLivingLinePet({ ...LIVING_LINE_PET, source: 'user' })).toBe(false); + expect(isLivingLinePet({ ...LIVING_LINE_PET, packagePath: '/other/living-line' })).toBe(false); + expect(isLivingLinePet(null)).toBe(false); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/services/livingLinePetIdentity.ts b/src/web-ui/src/infrastructure/config/services/livingLinePetIdentity.ts new file mode 100644 index 0000000000..15d11668b4 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/services/livingLinePetIdentity.ts @@ -0,0 +1,16 @@ +import type { AgentCompanionPetSelection } from './AIExperienceConfigService'; + +export const LIVING_LINE_PET: AgentCompanionPetSelection = { + id: 'living-line', + displayName: 'LivingLine', + description: 'An original animated line companion with expressive activity states.', + source: 'preset', + packagePath: '/agent-companion-pets/living-line', + spritesheetPath: '/agent-companion-pets/living-line/preview.svg', + spritesheetMimeType: 'image/svg+xml', +}; + +export function isLivingLinePet(pet: AgentCompanionPetSelection | null | undefined): boolean { + return pet?.source === 'preset' && pet.id === LIVING_LINE_PET.id + && pet.packagePath === LIVING_LINE_PET.packagePath; +} diff --git a/src/web-ui/src/locales/en-US/settings/runtime.json b/src/web-ui/src/locales/en-US/settings/runtime.json index b4d2373e50..9fc8df7ec9 100644 --- a/src/web-ui/src/locales/en-US/settings/runtime.json +++ b/src/web-ui/src/locales/en-US/settings/runtime.json @@ -34,6 +34,42 @@ "deleteConfirmBody": "Delete this imported pet from your device? This cannot be undone.", "deleteSuccess": "Imported appearance removed", "deleteFailed": "Failed to remove imported appearance", + "grok": { + "title": "GrokLive settings", + "size": "Size (px)", + "frameRate": "Frame rate target", + "auto": "Auto (up to 120)", + "follow": "Follow pointer", + "idleMotion": "Idle motion and blinks", + "glow": "Extra glow (may lower FPS)", + "showStats": "Debug frame statistics (dev build only)", + "reset": "Restore defaults", + "bodyColor": "Sphere color", + "bodyColor.black": "Black", + "bodyColor.darkGray": "Dark gray", + "bodyColor.gray": "Gray", + "bodyColor.custom": "Custom", + "eyeColor": "Eye color", + "eyeColor.white": "White", + "eyeColor.lightGray": "Light gray", + "eyeColor.custom": "Custom" + }, + "livingLine": { + "title": "LivingLine settings", + "reset": "Restore defaults", + "lineColor": "Line color", + "questionColor": "Question color", + "errorColor": "Error color", + "flowColor": "Flow color", + "lineWidth": "Line width", + "flowLength": "Flow length", + "flowWidth": "Flow width", + "flowBlur": "Flow blur", + "flowIntensity": "Flow intensity", + "lineGlowBlur": "Line glow blur", + "idlePeriodSeconds": "Idle period (seconds)", + "workingPeriodSeconds": "Working period (seconds)" + }, "presets": { "blueGolden": { "name": "困困" diff --git a/src/web-ui/src/locales/zh-CN/settings/runtime.json b/src/web-ui/src/locales/zh-CN/settings/runtime.json index 94116a4d8f..7640c50af7 100644 --- a/src/web-ui/src/locales/zh-CN/settings/runtime.json +++ b/src/web-ui/src/locales/zh-CN/settings/runtime.json @@ -34,6 +34,42 @@ "deleteConfirmBody": "从本机删除该已导入的宠物形象?此操作不可恢复。", "deleteSuccess": "已删除导入的形象", "deleteFailed": "删除导入形象失败", + "grok": { + "title": "GrokLive 设置", + "size": "大小(像素)", + "frameRate": "目标帧率", + "auto": "自动(最高 120)", + "follow": "视线跟随鼠标", + "idleMotion": "闲置动作和眨眼", + "glow": "额外发光(可能降低帧率)", + "showStats": "调试帧统计(仅开发版)", + "reset": "恢复默认", + "bodyColor": "球体颜色", + "bodyColor.black": "黑色", + "bodyColor.darkGray": "深灰色", + "bodyColor.gray": "灰色", + "bodyColor.custom": "自定义", + "eyeColor": "眼睛颜色", + "eyeColor.white": "白色", + "eyeColor.lightGray": "浅灰色", + "eyeColor.custom": "自定义" + }, + "livingLine": { + "title": "LivingLine 设置", + "reset": "恢复默认", + "lineColor": "线条颜色", + "questionColor": "提问颜色", + "errorColor": "错误颜色", + "flowColor": "流动颜色", + "lineWidth": "线条宽度", + "flowLength": "流动长度", + "flowWidth": "流动宽度", + "flowBlur": "流动模糊", + "flowIntensity": "流动强度", + "lineGlowBlur": "线条发光模糊", + "idlePeriodSeconds": "闲置周期(秒)", + "workingPeriodSeconds": "工作周期(秒)" + }, "presets": { "blueGolden": { "name": "困困" diff --git a/src/web-ui/src/locales/zh-TW/settings/runtime.json b/src/web-ui/src/locales/zh-TW/settings/runtime.json index 18a9ba7b6d..0cb855a461 100644 --- a/src/web-ui/src/locales/zh-TW/settings/runtime.json +++ b/src/web-ui/src/locales/zh-TW/settings/runtime.json @@ -34,6 +34,42 @@ "deleteConfirmBody": "從本機刪除此已匯入的寵物形象?此操作無法復原。", "deleteSuccess": "已刪除匯入的形象", "deleteFailed": "刪除匯入形象失敗", + "grok": { + "title": "GrokLive 設定", + "size": "大小(像素)", + "frameRate": "目標幀率", + "auto": "自動(最高 120)", + "follow": "視線跟隨滑鼠", + "idleMotion": "閒置動作與眨眼", + "glow": "額外發光(可能降低幀率)", + "showStats": "除錯幀統計(僅開發版)", + "reset": "恢復預設", + "bodyColor": "球體顏色", + "bodyColor.black": "黑色", + "bodyColor.darkGray": "深灰色", + "bodyColor.gray": "灰色", + "bodyColor.custom": "自訂", + "eyeColor": "眼睛顏色", + "eyeColor.white": "白色", + "eyeColor.lightGray": "淺灰色", + "eyeColor.custom": "自訂" + }, + "livingLine": { + "title": "LivingLine 設定", + "reset": "恢復預設", + "lineColor": "線條顏色", + "questionColor": "提問顏色", + "errorColor": "錯誤顏色", + "flowColor": "流動顏色", + "lineWidth": "線條寬度", + "flowLength": "流動長度", + "flowWidth": "流動寬度", + "flowBlur": "流動模糊", + "flowIntensity": "流動強度", + "lineGlowBlur": "線條發光模糊", + "idlePeriodSeconds": "閒置週期(秒)", + "workingPeriodSeconds": "工作週期(秒)" + }, "presets": { "blueGolden": { "name": "困困"