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)}
+ />
+
+
+ );
+ })}
+
+ );
+}
\ 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) })} />
+
+
+
+
+
+ updateGrok({ follow: event.target.checked })} />
+
+
+ updateGrok({ idleMotion: event.target.checked })} />
+
+
+ updateGrok({ glow: event.target.checked })} />
+
+
+
+
+
+ {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;
+ }
+ 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 && (
)}
+
+ );
+};
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 ;
+}
+
+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 LLINDEPENDENT MOTION STUDY
LivingLine
+ {state.toUpperCase()} / VECTOR STATE
+
+
+ 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}
+
+ ;
+}
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": "困困"