testing stuff

This commit is contained in:
smontlouis 2026-08-15 12:01:40 +02:00
parent e205c57687
commit 18abc5c82f
18 changed files with 4017 additions and 0 deletions

17
capture-lab/index.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0d1014" />
<meta
name="description"
content="Experimental local face capture and procedural avatar retargeting for Bible Strong Avatar Lab."
/>
<title>Capture Lab — Bible Strong Avatar Lab</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/features/capture/CaptureLabApp.tsx"></script>
</body>
</html>

18
grok-compare/index.html Normal file
View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#090a0c" />
<meta
name="description"
content="An interactive comparison between the official Grok Bot and its Bible Strong Avatar Lab remaster."
/>
<link rel="icon" type="image/svg+xml" href="../favicon.svg" />
<title>Grok Bot Remastered vs Official — Bible Strong Avatar Lab</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="../src/features/comparison/GrokComparisonApp.tsx"></script>
</body>
</html>

View File

@ -33,6 +33,7 @@
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.7.0", "@base-ui/react": "^1.7.0",
"@mediapipe/tasks-vision": "^1.0.1",
"@vercel/analytics": "^2.0.1", "@vercel/analytics": "^2.0.1",
"@vercel/speed-insights": "^2.0.0", "@vercel/speed-insights": "^2.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

8
pnpm-lock.yaml generated
View File

@ -11,6 +11,9 @@ importers:
'@base-ui/react': '@base-ui/react':
specifier: ^1.7.0 specifier: ^1.7.0
version: 1.7.0(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 1.7.0(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@mediapipe/tasks-vision':
specifier: ^1.0.1
version: 1.0.1
'@vercel/analytics': '@vercel/analytics':
specifier: ^2.0.1 specifier: ^2.0.1
version: 2.0.1(react@19.2.3) version: 2.0.1(react@19.2.3)
@ -219,6 +222,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@mediapipe/tasks-vision@1.0.1':
resolution: {integrity: sha512-rvRE2FmAZ6ZxKSw7wq+e+jQDpN3t1B/tD2mJz9SmAzb1msoDkd4dMoE4wAh8Z30Um0PQwLiHr9QtomhmXk3aUQ==}
'@oxc-project/types@0.144.0': '@oxc-project/types@0.144.0':
resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==}
@ -1222,6 +1228,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@mediapipe/tasks-vision@1.0.1': {}
'@oxc-project/types@0.144.0': {} '@oxc-project/types@0.144.0': {}
'@rolldown/binding-android-arm64@1.2.4': '@rolldown/binding-android-arm64@1.2.4':

View File

@ -0,0 +1,811 @@
import { Analytics } from '@vercel/analytics/react'
import {
Activity,
ArrowLeft,
Camera,
CameraOff,
Circle,
Crosshair,
RefreshCw,
ShieldCheck,
Square,
} from 'lucide-react'
import { motion, useMotionValue } from 'motion/react'
import { StrictMode, useEffect, useRef, useState, type CSSProperties } from 'react'
import { createRoot } from 'react-dom/client'
import { poseWithAvatarEyes, resolveColors } from '@/app/studio-utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { resolveAvatarBehavior } from '@/features/avatar/avatars'
import { clamp, poseFromExpression, renderAvatar } from '@/features/avatar/geometry'
import {
createFacialCaptureSmoother,
mirrorCaptureFrame,
observeExpression,
retargetCaptureFrame,
type ObservedExpression,
type FacialCaptureFrame,
} from '@/features/capture/facialCapture'
import { MotionTakeEditor } from '@/features/capture/MotionTakeEditor'
import {
defaultMotionTakeSettings,
processMotionTake,
sampleMotionTake,
type MotionSample,
type MotionTake,
type MotionTakeSettings,
} from '@/features/capture/motionTake'
import {
createRenderedScene,
paintRenderedOffset,
paintRenderedScene,
} from '@/features/rendering/renderedScene'
import { loadStudioDocument } from '@/features/studio/studioDocument'
import './captureLab.css'
type CaptureStatus = 'idle' | 'loading' | 'tracking' | 'lost' | 'error'
type CaptureWorkerMessage =
| { type: 'ready' }
| { type: 'capture'; frame: FacialCaptureFrame }
| { type: 'missing'; timestamp: number }
| { type: 'error'; message: string }
type Telemetry = {
pitch: number
yaw: number
roll: number
positionX: number
positionY: number
positionZ: number
blinkLeft: number
blinkRight: number
lookX: number
lookY: number
jawOpen: number
fps: number
}
const emptyObservedExpression: ObservedExpression = {
id: 'neutral',
label: 'Waiting for a face',
confidence: 0,
scores: { smile: 0, laugh: 0, angry: 0, surprised: 0, sad: 0 },
}
const defaultSmoothing = 58
const emptyTelemetry: Telemetry = {
pitch: 0,
yaw: 0,
roll: 0,
positionX: 0,
positionY: 0,
positionZ: 0,
blinkLeft: 0,
blinkRight: 0,
lookX: 0,
lookY: 0,
jawOpen: 0,
fps: 0,
}
function Signal({
label,
value,
signed = false,
}: {
label: string
value: number
signed?: boolean
}) {
const normalized = signed ? (value + 1) / 2 : value
return (
<div className="capture-signal">
<div className="capture-signal-label">
<span>{label}</span>
<output>{signed ? value.toFixed(2) : `${Math.round(value * 100)}%`}</output>
</div>
<div className={`capture-signal-track ${signed ? 'is-signed' : ''}`}>
{signed && <span className="capture-signal-zero" />}
<motion.span
className="capture-signal-fill"
animate={{ scaleX: Math.max(0.015, normalized) }}
transition={{ type: 'spring', stiffness: 340, damping: 38, mass: 0.5 }}
/>
</div>
</div>
)
}
function CaptureLabApp() {
const [studioDocument] = useState(() => loadStudioDocument())
const activeAvatar =
studioDocument.library.avatars.find(
avatar => avatar.id === studioDocument.library.activeAvatarId
) ?? studioDocument.library.avatars[0]
const behavior = resolveAvatarBehavior(activeAvatar, {
expressions: studioDocument.expressions,
sequences: studioDocument.sequences,
})
const baseExpression = poseWithAvatarEyes(behavior.expressions[0], activeAvatar.eyes).expression
const captureExpression = {
...baseExpression,
headX: 0,
headY: 0,
headZ: 0,
}
const initialGeometry = renderAvatar(
poseFromExpression(baseExpression),
activeAvatar.body.primary,
1,
{ includeWire: false, bodyNodes: activeAvatar.body.nodes }
)
const captureGeometry = renderAvatar(
poseFromExpression(captureExpression),
activeAvatar.body.primary,
1,
{ includeWire: false, bodyNodes: activeAvatar.body.nodes }
)
const [scene] = useState(() => createRenderedScene(initialGeometry))
const captureScale = useMotionValue(1)
const [status, setStatus] = useState<CaptureStatus>('idle')
const [errorMessage, setErrorMessage] = useState('')
const [telemetry, setTelemetry] = useState<Telemetry>(emptyTelemetry)
const [observedExpression, setObservedExpression] = useState(emptyObservedExpression)
const [calibrated, setCalibrated] = useState(false)
const [smoothing, setSmoothing] = useState(defaultSmoothing)
const [recording, setRecording] = useState(false)
const [recordingDuration, setRecordingDuration] = useState(0)
const [take, setTake] = useState<MotionTake | null>(null)
const [processedTake, setProcessedTake] = useState<MotionTake | null>(null)
const [takeSettings, setTakeSettings] = useState<MotionTakeSettings>(defaultMotionTakeSettings)
const [playhead, setPlayhead] = useState(0)
const [playing, setPlaying] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null)
const streamRef = useRef<MediaStream | null>(null)
const workerRef = useRef<Worker | null>(null)
const frameRequestRef = useRef<number | null>(null)
const workerReadyRef = useRef(false)
const cameraReadyRef = useRef(false)
const processingRef = useRef(false)
const lastVideoTimeRef = useRef(-1)
const neutralRef = useRef<FacialCaptureFrame | null>(null)
const latestFrameRef = useRef<FacialCaptureFrame | null>(null)
const lastTelemetryAtRef = useRef(0)
const lastCaptureAtRef = useRef(0)
const smootherRef = useRef(createFacialCaptureSmoother(defaultSmoothing / 100))
const recordingRef = useRef(false)
const recordingStartedAtRef = useRef<number | null>(null)
const recordingSamplesRef = useRef<MotionSample[]>([])
const lastRecordingUiAtRef = useRef(0)
const playbackFrameRef = useRef<number | null>(null)
const processedTakeRef = useRef<MotionTake | null>(null)
const lastPlaybackUiAtRef = useRef(0)
const colors = resolveColors(baseExpression, activeAvatar.colors)
const resetAvatar = () => {
paintRenderedScene(scene, initialGeometry)
paintRenderedOffset(scene, { x: 0, y: 0 })
captureScale.set(1)
setTelemetry(emptyTelemetry)
setObservedExpression(emptyObservedExpression)
}
const paintMotionSample = (sample: MotionSample) => {
const expression = {
...captureExpression,
headX: captureExpression.headX + sample.pitch,
headY: captureExpression.headY + sample.yaw,
headZ: captureExpression.headZ + sample.roll,
positionXLeft: captureExpression.positionXLeft + sample.lookX * 7.5,
positionXRight: captureExpression.positionXRight + sample.lookX * 7.5,
positionYLeft: captureExpression.positionYLeft - sample.lookY * 6,
positionYRight: captureExpression.positionYRight - sample.lookY * 6,
}
paintRenderedScene(
scene,
renderAvatar(poseFromExpression(expression), activeAvatar.body.primary, 1 - sample.blink, {
includeWire: false,
bodyNodes: activeAvatar.body.nodes,
})
)
paintRenderedOffset(scene, { x: sample.positionX, y: sample.positionY })
captureScale.set(clamp(1 + sample.positionZ, 0.62, 1.52))
}
const paintCapture = (captured: FacialCaptureFrame) => {
const mirrored = mirrorCaptureFrame(captured)
const frame = smootherRef.current.next(mirrored)
latestFrameRef.current = frame
if (!neutralRef.current) {
neutralRef.current = frame
setCalibrated(true)
}
const neutral = neutralRef.current
const retargeted = retargetCaptureFrame(frame, neutral, captureExpression)
if (recordingRef.current) {
const raw = retargetCaptureFrame(mirrored, neutral, captureExpression)
const startedAt = recordingStartedAtRef.current ?? mirrored.timestamp
recordingStartedAtRef.current = startedAt
const time = mirrored.timestamp - startedAt
recordingSamplesRef.current.push({
time,
pitch: raw.expression.headX - captureExpression.headX,
yaw: raw.expression.headY - captureExpression.headY,
roll: raw.expression.headZ - captureExpression.headZ,
positionX: raw.signals.positionX,
positionY: raw.signals.positionY,
positionZ: raw.signals.positionZ,
lookX: raw.signals.lookX,
lookY: raw.signals.lookY,
blink: 1 - raw.blinkAmount,
})
if (mirrored.timestamp - lastRecordingUiAtRef.current >= 90) {
lastRecordingUiAtRef.current = mirrored.timestamp
setRecordingDuration(time)
}
}
paintRenderedScene(
scene,
renderAvatar(
poseFromExpression(retargeted.expression),
activeAvatar.body.primary,
retargeted.blinkAmount,
{ includeWire: false, bodyNodes: activeAvatar.body.nodes }
)
)
paintRenderedOffset(scene, {
x: retargeted.signals.positionX,
y: retargeted.signals.positionY,
})
captureScale.set(clamp(1 + retargeted.signals.positionZ, 0.62, 1.52))
if (frame.timestamp - lastTelemetryAtRef.current >= 90) {
const elapsed = frame.timestamp - lastCaptureAtRef.current
const fps = elapsed > 0 ? 1000 / elapsed : 0
lastTelemetryAtRef.current = frame.timestamp
setTelemetry({
pitch: frame.head.pitch - neutral.head.pitch,
yaw: frame.head.yaw - neutral.head.yaw,
roll: frame.head.roll - neutral.head.roll,
positionX: retargeted.signals.positionX,
positionY: retargeted.signals.positionY,
positionZ: retargeted.signals.positionZ,
blinkLeft: retargeted.signals.blinkLeft,
blinkRight: retargeted.signals.blinkRight,
lookX: retargeted.signals.lookX,
lookY: retargeted.signals.lookY,
jawOpen: frame.mouth.jawOpen,
fps,
})
setObservedExpression(observeExpression(frame))
}
lastCaptureAtRef.current = frame.timestamp
}
const stopFrameLoop = () => {
if (frameRequestRef.current !== null) cancelAnimationFrame(frameRequestRef.current)
frameRequestRef.current = null
processingRef.current = false
lastVideoTimeRef.current = -1
}
const stopCameraStream = () => {
streamRef.current?.getTracks().forEach(track => track.stop())
streamRef.current = null
if (videoRef.current) videoRef.current.srcObject = null
cameraReadyRef.current = false
}
const frameLoop = () => {
const video = videoRef.current
const worker = workerRef.current
if (
video &&
worker &&
video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
!processingRef.current &&
video.currentTime !== lastVideoTimeRef.current
) {
processingRef.current = true
lastVideoTimeRef.current = video.currentTime
createImageBitmap(video)
.then(image => {
worker.postMessage({ type: 'frame', image, timestamp: performance.now() }, [image])
})
.catch(error => {
processingRef.current = false
setStatus('error')
setErrorMessage(error instanceof Error ? error.message : 'Unable to read the camera.')
})
}
frameRequestRef.current = requestAnimationFrame(frameLoop)
}
const startFrameLoop = () => {
if (frameRequestRef.current !== null || !workerReadyRef.current || !cameraReadyRef.current)
return
frameRequestRef.current = requestAnimationFrame(frameLoop)
}
const ensureWorker = () => {
if (workerRef.current) return
const worker = new Worker(new URL('./faceLandmarker.worker.ts', import.meta.url), {
type: 'module',
})
workerRef.current = worker
worker.addEventListener('message', (event: MessageEvent<CaptureWorkerMessage>) => {
if (event.data.type === 'ready') {
workerReadyRef.current = true
startFrameLoop()
return
}
processingRef.current = false
if (event.data.type === 'capture') {
setStatus('tracking')
paintCapture(event.data.frame)
} else if (event.data.type === 'missing') {
setStatus('lost')
} else {
setStatus('error')
setErrorMessage(event.data.message)
stopFrameLoop()
stopCameraStream()
}
})
worker.postMessage({ type: 'initialize' })
}
const startCamera = async () => {
if (!navigator.mediaDevices?.getUserMedia) {
setStatus('error')
setErrorMessage('Camera access is not supported in this browser.')
return
}
setStatus('loading')
setErrorMessage('')
setTake(null)
setProcessedTake(null)
setPlayhead(0)
neutralRef.current = null
latestFrameRef.current = null
smootherRef.current.reset()
setCalibrated(false)
setTelemetry(emptyTelemetry)
paintRenderedScene(scene, captureGeometry)
paintRenderedOffset(scene, { x: 0, y: 0 })
captureScale.set(1)
ensureWorker()
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user', width: { ideal: 960 }, height: { ideal: 720 } },
audio: false,
})
streamRef.current = stream
const video = videoRef.current
if (!video) return
video.srcObject = stream
await video.play()
cameraReadyRef.current = true
startFrameLoop()
} catch (error) {
stopFrameLoop()
stopCameraStream()
setStatus('error')
setErrorMessage(
error instanceof DOMException && error.name === 'NotAllowedError'
? 'Camera permission was declined. You can allow it from the browser address bar.'
: error instanceof Error
? error.message
: 'Unable to start the camera.'
)
}
}
const stopCamera = () => {
stopFrameLoop()
stopCameraStream()
neutralRef.current = null
latestFrameRef.current = null
smootherRef.current.reset()
setCalibrated(false)
setStatus('idle')
recordingRef.current = false
setRecording(false)
resetAvatar()
}
const calibrate = () => {
if (!latestFrameRef.current) return
neutralRef.current = latestFrameRef.current
setCalibrated(true)
}
const updateSmoothing = (nextSmoothing: number) => {
setSmoothing(nextSmoothing)
smootherRef.current.setSmoothing(nextSmoothing / 100)
}
const stopPlayback = () => {
if (playbackFrameRef.current !== null) cancelAnimationFrame(playbackFrameRef.current)
playbackFrameRef.current = null
setPlaying(false)
}
const seekTake = (time: number) => {
stopPlayback()
const processed = processedTakeRef.current
if (!processed) return
const bounded = Math.max(0, Math.min(processed.duration, time))
setPlayhead(bounded)
const sample = sampleMotionTake(processed, bounded)
if (sample) paintMotionSample(sample)
}
const togglePlayback = () => {
if (playing) {
stopPlayback()
return
}
const processed = processedTakeRef.current
if (!processed) return
const initialTime = playhead >= processed.duration ? 0 : playhead
const startedAt = performance.now() - initialTime
setPlaying(true)
const tick = (now: number) => {
const current = Math.min(processed.duration, now - startedAt)
const sample = sampleMotionTake(processed, current)
if (sample) paintMotionSample(sample)
if (now - lastPlaybackUiAtRef.current >= 45 || current >= processed.duration) {
lastPlaybackUiAtRef.current = now
setPlayhead(current)
}
if (current >= processed.duration) {
playbackFrameRef.current = null
setPlaying(false)
return
}
playbackFrameRef.current = requestAnimationFrame(tick)
}
playbackFrameRef.current = requestAnimationFrame(tick)
}
const beginRecording = () => {
if (status !== 'tracking' || !neutralRef.current) return
stopPlayback()
recordingSamplesRef.current = []
recordingStartedAtRef.current = null
lastRecordingUiAtRef.current = 0
recordingRef.current = true
setRecordingDuration(0)
setRecording(true)
}
const finishRecording = () => {
recordingRef.current = false
setRecording(false)
const samples = recordingSamplesRef.current
if (samples.length < 2) return
const recordedTake = { duration: samples.at(-1)?.time ?? 0, samples: [...samples] }
const processed = processMotionTake(recordedTake, takeSettings)
processedTakeRef.current = processed
setTake(recordedTake)
setProcessedTake(processed)
setPlayhead(0)
stopFrameLoop()
stopCameraStream()
setStatus('idle')
const first = sampleMotionTake(processed, 0)
if (first) paintMotionSample(first)
}
const updateTakeSettings = (settings: MotionTakeSettings) => {
setTakeSettings(settings)
if (!take) return
const processed = processMotionTake(take, settings)
processedTakeRef.current = processed
setProcessedTake(processed)
const nextPlayhead = Math.min(playhead, processed.duration)
setPlayhead(nextPlayhead)
const sample = sampleMotionTake(processed, nextPlayhead)
if (sample) paintMotionSample(sample)
}
const retake = () => {
stopPlayback()
setTake(null)
setProcessedTake(null)
processedTakeRef.current = null
setPlayhead(0)
requestAnimationFrame(() => void startCamera())
}
useEffect(
() => () => {
stopFrameLoop()
if (playbackFrameRef.current !== null) cancelAnimationFrame(playbackFrameRef.current)
streamRef.current?.getTracks().forEach(track => track.stop())
workerRef.current?.terminate()
},
[]
)
const statusLabel = {
idle: 'Camera off',
loading: 'Loading tracker',
tracking: 'Face locked',
lost: 'Find your face',
error: 'Tracker unavailable',
}[status]
const isCameraActive = status === 'tracking' || status === 'lost' || status === 'loading'
return (
<main
className="capture-lab"
style={
{
'--capture-body': colors.body,
'--capture-eyes': colors.eyes,
} as CSSProperties
}
>
<header className="capture-header">
<a className="capture-back" href="../">
<ArrowLeft />
Avatar Lab
</a>
<div className="capture-title-lockup">
<span>Experimental instrument · 01</span>
<h1>Performance Capture</h1>
</div>
<Badge className="capture-privacy-badge" variant="outline">
<ShieldCheck /> Local processing
</Badge>
</header>
<section className="capture-workbench">
<div className="capture-stage">
<div className="capture-stage-grid" aria-hidden="true" />
<div className="capture-avatar-label">
<span>{take ? 'Refined playback' : 'Live retarget'}</span>
<strong>{activeAvatar.name}</strong>
</div>
<svg className="capture-avatar" viewBox="-150 -150 300 300" role="img">
<title>{activeAvatar.name} driven by facial capture</title>
<defs>
<clipPath id="capture-head-clip">
<motion.path d={scene.headPath} />
</clipPath>
</defs>
<motion.g
style={{
x: scene.offsetX,
y: scene.offsetY,
scale: captureScale,
transformOrigin: '0px 0px',
}}
>
{scene.backPaths.map((path, index) => (
<motion.path className="capture-avatar-body" d={path} key={`back-${index}`} />
))}
<motion.path className="capture-avatar-body" d={scene.headPath} />
<g clipPath="url(#capture-head-clip)">
<motion.path
className="capture-avatar-eye"
d={scene.leftPath}
opacity={scene.leftOpacity}
/>
<motion.path
className="capture-avatar-eye"
d={scene.rightPath}
opacity={scene.rightOpacity}
/>
</g>
{scene.frontPaths.map((path, index) => (
<motion.path className="capture-avatar-body" d={path} key={`front-${index}`} />
))}
</motion.g>
</svg>
<div className={`capture-lock-status is-${take ? 'take' : status}`}>
<span />
{take ? 'Take ready' : statusLabel}
</div>
<div className={`capture-camera-card ${take ? 'is-hidden' : ''}`}>
<video ref={videoRef} muted playsInline aria-label="Webcam preview" />
<div className="capture-camera-reticle" aria-hidden="true">
<i />
<i />
<i />
<i />
</div>
{!isCameraActive && (
<div className="capture-camera-placeholder">
<Camera />
<span>Camera preview</span>
</div>
)}
<span className="capture-camera-caption">SOURCE / LOCAL</span>
</div>
</div>
<aside className="capture-console">
{take && processedTake ? (
<MotionTakeEditor
take={take}
processedTake={processedTake}
settings={takeSettings}
playhead={playhead}
playing={playing}
onSettingsChange={updateTakeSettings}
onSeek={seekTake}
onTogglePlayback={togglePlayback}
onRetake={retake}
/>
) : (
<>
<div className="capture-console-heading">
<div>
<span>Signal console</span>
<h2>Face → procedural rig</h2>
</div>
<Activity className={status === 'tracking' ? 'is-active' : ''} />
</div>
<div className="capture-orientation">
<div>
<span>Pitch</span>
<strong>{telemetry.pitch.toFixed(1)}°</strong>
</div>
<div>
<span>Yaw</span>
<strong>{telemetry.yaw.toFixed(1)}°</strong>
</div>
<div>
<span>Roll</span>
<strong>{telemetry.roll.toFixed(1)}°</strong>
</div>
</div>
<div className="capture-position">
<div>
<span>Move X</span>
<strong>{telemetry.positionX.toFixed(1)}</strong>
</div>
<div>
<span>Move Y</span>
<strong>{telemetry.positionY.toFixed(1)}</strong>
</div>
<div>
<span>Depth</span>
<strong>{telemetry.positionZ.toFixed(2)}</strong>
</div>
</div>
<div className="capture-signal-group">
<Signal label="Left blink" value={telemetry.blinkLeft} />
<Signal label="Right blink" value={telemetry.blinkRight} />
<Signal label="Gaze horizontal" value={telemetry.lookX} signed />
<Signal label="Gaze vertical" value={telemetry.lookY} signed />
</div>
<div className="capture-smoothing-control">
<div className="capture-smoothing-heading">
<label htmlFor="capture-smoothing">Motion smoothing</label>
<output htmlFor="capture-smoothing">{smoothing}%</output>
</div>
<input
id="capture-smoothing"
type="range"
min="0"
max="100"
step="1"
value={smoothing}
onChange={event => updateSmoothing(Number(event.currentTarget.value))}
/>
<div className="capture-smoothing-scale" aria-hidden="true">
<span>Responsive</span>
<span>Fluid</span>
</div>
</div>
<div className="capture-future-signal">
<div>
<span>Reserved channel</span>
<strong>Mouth / jaw</strong>
</div>
<output>{Math.round(telemetry.jawOpen * 100)}%</output>
</div>
<section className="capture-expression-observer" aria-live="polite">
<div className="capture-expression-summary">
<div>
<span>Expression observer</span>
<strong>{observedExpression.label}</strong>
</div>
<output>{Math.round(observedExpression.confidence * 100)}%</output>
</div>
<div className="capture-expression-scores">
{Object.entries(observedExpression.scores).map(([label, value]) => (
<div key={label}>
<span>{label === 'laugh' ? 'Laugh-like' : label}</span>
<i aria-hidden="true">
<motion.b
animate={{ scaleX: Math.max(0.01, value) }}
transition={{ type: 'spring', stiffness: 280, damping: 34, mass: 0.55 }}
/>
</i>
<output>{Math.round(value * 100)}</output>
</div>
))}
</div>
<p>Visual estimate only. It does not affect the avatar rig.</p>
</section>
<div className="capture-calibration-card">
<Crosshair />
<div>
<strong>
{calibrated ? 'Neutral pose calibrated' : 'Neutral pose required'}
</strong>
<p>Look forward, relax your eyes, then capture a new neutral reference.</p>
</div>
<Button
variant="outline"
size="icon"
aria-label="Calibrate neutral pose"
disabled={!latestFrameRef.current}
onClick={calibrate}
>
<RefreshCw />
</Button>
</div>
{errorMessage && <p className="capture-error-message">{errorMessage}</p>}
<div className="capture-console-actions">
{isCameraActive ? (
<>
<Button
className={`capture-record-button ${recording ? 'is-recording' : ''}`}
disabled={status !== 'tracking'}
onClick={recording ? finishRecording : beginRecording}
>
{recording ? <Square /> : <Circle />}
{recording ? 'Finish take' : 'Record a take'}
</Button>
<Button className="capture-stop-button" variant="outline" onClick={stopCamera}>
<CameraOff /> Stop camera
</Button>
</>
) : (
<Button className="capture-start-button" onClick={startCamera}>
<Camera /> Start capture
</Button>
)}
<span>
{recording
? `REC ${(recordingDuration / 1000).toFixed(1)}s`
: telemetry.fps
? `${Math.round(telemetry.fps)} FPS`
: '— FPS'}
</span>
</div>
<p className="capture-privacy-note">
Frames are analyzed on this device and are never stored or uploaded.
</p>
</>
)}
</aside>
</section>
</main>
)
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<CaptureLabApp />
<Analytics />
</StrictMode>
)

View File

@ -0,0 +1,221 @@
import { Pause, Play, RotateCcw, Video } from 'lucide-react'
import type { PointerEvent } from 'react'
import { Button } from '@/components/ui/button'
import {
motionChannels,
type MotionChannel,
type MotionTake,
type MotionTakeSettings,
} from '@/features/capture/motionTake'
const channels: { channel: MotionChannel; label: string; range: number; color: string }[] = [
{ channel: 'pitch', label: 'Pitch', range: 38, color: '#567ef0' },
{ channel: 'yaw', label: 'Yaw', range: 52, color: '#ef725f' },
{ channel: 'roll', label: 'Roll', range: 42, color: '#d2a83f' },
{ channel: 'positionX', label: 'Move X', range: 55, color: '#d05a87' },
{ channel: 'positionY', label: 'Move Y', range: 55, color: '#62a8c6' },
{ channel: 'positionZ', label: 'Depth', range: 0.48, color: '#d67b3b' },
{ channel: 'lookX', label: 'Gaze X', range: 1, color: '#2f9f83' },
{ channel: 'lookY', label: 'Gaze Y', range: 1, color: '#9670d6' },
{ channel: 'blink', label: 'Blink', range: 1, color: '#34383f' },
]
const curvePoints = (take: MotionTake, channel: MotionChannel, range: number) =>
take.samples.map(sample => {
const x = take.duration ? (sample.time / take.duration) * 100 : 0
const normalized = channel === 'blink' ? sample[channel] * 2 - 1 : sample[channel] / range
return { x, y: 12 - Math.max(-1, Math.min(1, normalized)) * 9 }
})
const curvePath = (take: MotionTake, channel: MotionChannel, range: number, rounded = false) => {
if (take.samples.length === 0) return ''
const points = curvePoints(take, channel, range)
if (!rounded || points.length < 3)
return points
.map(
(point, index) => `${index === 0 ? 'M' : 'L'}${point.x.toFixed(2)} ${point.y.toFixed(2)}`
)
.join(' ')
return points.slice(0, -1).reduce(
(path, point, index) => {
const before = points[Math.max(0, index - 1)]
const next = points[index + 1]
const after = points[Math.min(points.length - 1, index + 2)]
const controlA = {
x: point.x + (next.x - before.x) / 6,
y: point.y + (next.y - before.y) / 6,
}
const controlB = {
x: next.x - (after.x - point.x) / 6,
y: next.y - (after.y - point.y) / 6,
}
return `${path} C${controlA.x.toFixed(2)} ${controlA.y.toFixed(2)} ${controlB.x.toFixed(2)} ${controlB.y.toFixed(2)} ${next.x.toFixed(2)} ${next.y.toFixed(2)}`
},
`M${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`
)
}
function EditorSlider({
label,
value,
min,
max,
step,
format,
onChange,
}: {
label: string
value: number
min: number
max: number
step: number
format: (value: number) => string
onChange: (value: number) => void
}) {
const id = `take-${label.toLowerCase()}`
return (
<div className="take-editor-slider">
<div>
<label htmlFor={id}>{label}</label>
<output htmlFor={id}>{format(value)}</output>
</div>
<input
id={id}
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={event => onChange(Number(event.currentTarget.value))}
/>
</div>
)
}
export function MotionTakeEditor({
take,
processedTake,
settings,
playhead,
playing,
onSettingsChange,
onSeek,
onTogglePlayback,
onRetake,
}: {
take: MotionTake
processedTake: MotionTake
settings: MotionTakeSettings
playhead: number
playing: boolean
onSettingsChange: (settings: MotionTakeSettings) => void
onSeek: (time: number) => void
onTogglePlayback: () => void
onRetake: () => void
}) {
const seekFromPointer = (event: PointerEvent<HTMLDivElement>) => {
const bounds = event.currentTarget.getBoundingClientRect()
onSeek(((event.clientX - bounds.left) / bounds.width) * processedTake.duration)
}
const playheadX = processedTake.duration ? (playhead / processedTake.duration) * 100 : 0
return (
<div className="take-editor">
<div className="take-editor-heading">
<div>
<span>Recorded take · 01</span>
<h2>Refine the gesture</h2>
</div>
<div className="take-editor-duration">
<strong>{(processedTake.duration / 1000).toFixed(2)}s</strong>
<span>{take.samples.length} samples</span>
</div>
</div>
<div className="take-editor-legend">
<span className="is-raw">Raw capture</span>
<span className="is-refined">Refined curve</span>
</div>
<div
className="take-timeline"
onPointerDown={event => {
event.currentTarget.setPointerCapture(event.pointerId)
seekFromPointer(event)
}}
onPointerMove={event => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) seekFromPointer(event)
}}
>
<span className="take-playhead" style={{ left: `${playheadX}%` }} />
{channels.map(({ channel, label, range, color }) => (
<div className="take-channel" key={channel}>
<span>{label}</span>
<svg viewBox="0 0 100 24" preserveAspectRatio="none" aria-label={`${label} curve`}>
<line x1="0" y1="12" x2="100" y2="12" />
<path className="take-raw-curve" d={curvePath(take, channel, range)} />
<path
className="take-refined-curve"
d={curvePath(processedTake, channel, range, true)}
style={{ stroke: color }}
/>
</svg>
</div>
))}
</div>
<div className="take-editor-controls">
<EditorSlider
label="Curve smoothness"
value={settings.smoothing}
min={0}
max={1}
step={0.01}
format={value => `${Math.round(value * 100)}%`}
onChange={smoothing => onSettingsChange({ ...settings, smoothing })}
/>
<EditorSlider
label="Detail retention"
value={settings.detailRetention}
min={0}
max={1}
step={0.01}
format={value => `${Math.round(value * 100)}%`}
onChange={detailRetention => onSettingsChange({ ...settings, detailRetention })}
/>
<EditorSlider
label="Amplitude"
value={settings.amplitude}
min={0}
max={2}
step={0.01}
format={value => `${value.toFixed(2)}×`}
onChange={amplitude => onSettingsChange({ ...settings, amplitude })}
/>
<EditorSlider
label="Speed"
value={settings.speed}
min={0.25}
max={2.5}
step={0.05}
format={value => `${value.toFixed(2)}×`}
onChange={speed => onSettingsChange({ ...settings, speed })}
/>
</div>
<div className="take-editor-actions">
<Button className="take-play-button" onClick={onTogglePlayback}>
{playing ? <Pause /> : <Play />}
{playing ? 'Pause preview' : 'Preview take'}
</Button>
<Button variant="outline" onClick={onRetake}>
<RotateCcw /> Retake
</Button>
<span>
<Video /> Offline curve processing
</span>
</div>
</div>
)
}

View File

@ -0,0 +1,141 @@
import { defaultExpression } from '@/features/avatar/presets'
import {
captureFrameFromMediaPipe,
createFacialCaptureSmoother,
headCaptureFromMatrix,
mirrorCaptureFrame,
observeExpression,
positionCaptureFromMatrix,
retargetCaptureFrame,
} from '@/features/capture/facialCapture'
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
describe('facial capture adapter', () => {
it('extracts a neutral orientation from an identity transform', () => {
expect(headCaptureFromMatrix(identity)).toEqual({ pitch: 0, yaw: -0, roll: 0 })
})
it('extracts metric translation from the facial transform', () => {
const translated = [...identity]
translated[12] = 2.5
translated[13] = -1.25
translated[14] = -42
expect(positionCaptureFromMatrix(translated)).toEqual({ x: 2.5, y: -1.25, z: -42 })
})
it('keeps eye and future mouth signals from MediaPipe', () => {
const frame = captureFrameFromMediaPipe(
{
eyeBlinkLeft: 0.8,
eyeWideRight: 0.35,
eyeLookOutLeft: 0.6,
eyeLookInRight: 0.6,
jawOpen: 0.7,
mouthSmileLeft: 0.4,
},
identity,
42
)
expect(frame.eyes.left.blink).toBe(0.8)
expect(frame.eyes.right.wide).toBe(0.35)
expect(frame.eyes.lookX).toBe(0.6)
expect(frame.mouth.jawOpen).toBe(0.7)
expect(frame.mouth.smileLeft).toBe(0.4)
expect(frame.brows.downLeft).toBe(0)
})
it('observes expressive facial patterns without changing retargeting', () => {
const smiling = captureFrameFromMediaPipe(
{
mouthSmileLeft: 0.92,
mouthSmileRight: 0.88,
eyeSquintLeft: 0.5,
eyeSquintRight: 0.46,
jawOpen: 0.04,
},
identity,
16
)
const angry = captureFrameFromMediaPipe(
{
browDownLeft: 0.9,
browDownRight: 0.84,
mouthPressLeft: 0.72,
mouthPressRight: 0.68,
},
identity,
32
)
expect(observeExpression(smiling).id).toBe('smile')
expect(observeExpression(angry).id).toBe('angry')
})
it('retargets rotation, gaze and blink relative to calibration', () => {
const neutral = captureFrameFromMediaPipe({}, identity, 0)
const performance = captureFrameFromMediaPipe(
{ eyeBlinkLeft: 0.9, eyeBlinkRight: 0.85, eyeLookUpLeft: 0.5, eyeLookUpRight: 0.5 },
identity,
16
)
performance.head.yaw = 20
performance.position = { x: 2, y: -3, z: 4 }
const retargeted = retargetCaptureFrame(performance, neutral, defaultExpression)
expect(retargeted.expression.headY).toBe(-20)
expect(retargeted.expression.positionYLeft).toBeLessThan(defaultExpression.positionYLeft)
expect(retargeted.blinkAmount).toBeCloseTo(0.035)
expect(retargeted.signals.positionX).toBe(10)
expect(retargeted.signals.positionY).toBe(15)
expect(retargeted.signals.positionZ).toBeCloseTo(0.14)
})
it('mirrors horizontal movement without double-inverting renderer roll or blink channels', () => {
const frame = captureFrameFromMediaPipe(
{ eyeBlinkLeft: 0.8, eyeBlinkRight: 0.2, eyeLookOutLeft: 0.6, eyeLookInRight: 0.6 },
identity,
16
)
frame.head = { pitch: 8, yaw: 21, roll: -12 }
frame.position = { x: 4, y: -2, z: -40 }
const mirrored = mirrorCaptureFrame(frame)
expect(mirrored.head).toEqual({ pitch: 8, yaw: -21, roll: -12 })
expect(mirrored.eyes.lookX).toBe(-0.6)
expect(mirrored.eyes.left.blink).toBe(0.8)
expect(mirrored.eyes.right.blink).toBe(0.2)
expect(mirrored.position).toEqual({ x: -4, y: -2, z: -40 })
})
it('smooths discontinuities while converging toward the latest frame', () => {
const smoother = createFacialCaptureSmoother()
const first = captureFrameFromMediaPipe({}, identity, 0)
const next = captureFrameFromMediaPipe({ eyeBlinkLeft: 1 }, identity, 16)
next.head.pitch = 30
smoother.next(first)
const smoothed = smoother.next(next)
expect(smoothed.head.pitch).toBeGreaterThan(0)
expect(smoothed.head.pitch).toBeLessThan(30)
expect(smoothed.eyes.left.blink).toBeGreaterThan(0)
expect(smoothed.eyes.left.blink).toBeLessThan(1)
})
it('makes movement more inertial as smoothing increases', () => {
const responsive = createFacialCaptureSmoother(0)
const fluid = createFacialCaptureSmoother(1)
const first = captureFrameFromMediaPipe({}, identity, 0)
const next = captureFrameFromMediaPipe({}, identity, 16)
next.head.yaw = 30
responsive.next(first)
fluid.next(first)
expect(responsive.next(next).head.yaw).toBeGreaterThan(fluid.next(next).head.yaw)
})
})

View File

@ -0,0 +1,85 @@
import {
defaultMotionTakeSettings,
processMotionTake,
sampleMotionTake,
type MotionTake,
} from '@/features/capture/motionTake'
const take: MotionTake = {
duration: 40,
samples: [
{
time: 0,
pitch: 0,
yaw: 0,
roll: 0,
positionX: 0,
positionY: 0,
positionZ: 0,
lookX: 0,
lookY: 0,
blink: 0,
},
{
time: 20,
pitch: 30,
yaw: -20,
roll: 10,
positionX: 24,
positionY: -12,
positionZ: 0.25,
lookX: 1,
lookY: -1,
blink: 1,
},
{
time: 40,
pitch: 0,
yaw: 0,
roll: 0,
positionX: 0,
positionY: 0,
positionZ: 0,
lookX: 0,
lookY: 0,
blink: 0,
},
],
}
describe('recorded motion take processing', () => {
it('uses future and past samples to soften a captured spike', () => {
const processed = processMotionTake(take, {
...defaultMotionTakeSettings,
smoothing: 1,
detailRetention: 0,
})
expect(processed.samples[1].pitch).toBeGreaterThan(0)
expect(processed.samples[1].pitch).toBeLessThan(30)
expect(processed.samples[0].pitch).toBe(0)
})
it('preserves raw movement at full detail while applying amplitude and speed', () => {
const processed = processMotionTake(take, {
smoothing: 1,
detailRetention: 1,
amplitude: 1.5,
speed: 2,
})
expect(processed.samples[1].pitch).toBe(45)
expect(processed.samples[1].blink).toBe(1)
expect(processed.samples[1].positionX).toBe(36)
expect(processed.duration).toBe(20)
})
it('interpolates processed channels at an arbitrary playhead time', () => {
const sample = sampleMotionTake(take, 10)
expect(sample?.pitch).toBeGreaterThan(14)
expect(sample?.pitch).toBeLessThan(18)
expect(sample?.yaw).toBeLessThan(-9)
expect(sample?.blink).toBeGreaterThan(0.45)
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,64 @@
import { FaceLandmarker, FilesetResolver } from '@mediapipe/tasks-vision'
import { captureFrameFromMediaPipe } from './facialCapture'
const WASM_ROOT = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@1.0.1/wasm'
const MODEL_URL =
'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task'
type WorkerRequest =
{ type: 'initialize' } | { type: 'frame'; image: ImageBitmap; timestamp: number }
let landmarker: FaceLandmarker | null = null
const initialize = async () => {
if (landmarker) return
// Module workers cannot expose the classic loader's local ModuleFactory.
// The module build publishes it on globalThis for MediaPipe's task runner.
const vision = await FilesetResolver.forVisionTasks(WASM_ROOT, true)
landmarker = await FaceLandmarker.createFromOptions(vision, {
baseOptions: { modelAssetPath: MODEL_URL },
runningMode: 'VIDEO',
numFaces: 1,
minFaceDetectionConfidence: 0.55,
minFacePresenceConfidence: 0.55,
minTrackingConfidence: 0.55,
outputFaceBlendshapes: true,
outputFacialTransformationMatrixes: true,
})
}
self.addEventListener('message', async (event: MessageEvent<WorkerRequest>) => {
try {
if (event.data.type === 'initialize') {
await initialize()
self.postMessage({ type: 'ready' })
return
}
const { image, timestamp } = event.data
try {
if (!landmarker) await initialize()
const result = landmarker!.detectForVideo(image, timestamp)
const classification = result.faceBlendshapes[0]
const matrix = result.facialTransformationMatrixes[0]
if (!classification || !matrix) {
self.postMessage({ type: 'missing', timestamp })
return
}
const scores = Object.fromEntries(
classification.categories.map(category => [category.categoryName, category.score])
)
self.postMessage({
type: 'capture',
frame: captureFrameFromMediaPipe(scores, matrix.data, timestamp),
})
} finally {
image.close()
}
} catch (error) {
self.postMessage({
type: 'error',
message: error instanceof Error ? error.message : 'Face tracking failed.',
})
}
})

View File

@ -0,0 +1,326 @@
import { clamp, type Expression } from '@/features/avatar/geometry'
export type HeadCapture = {
pitch: number
yaw: number
roll: number
}
export type PositionCapture = {
x: number
y: number
z: number
}
export type EyeCapture = {
blink: number
wide: number
squint: number
}
export type MouthCapture = {
jawOpen: number
smileLeft: number
smileRight: number
frownLeft: number
frownRight: number
pucker: number
funnel: number
left: number
right: number
pressLeft: number
pressRight: number
}
export type BrowCapture = {
downLeft: number
downRight: number
innerUp: number
outerUpLeft: number
outerUpRight: number
}
export type FacialCaptureFrame = {
timestamp: number
head: HeadCapture
position: PositionCapture
eyes: {
left: EyeCapture
right: EyeCapture
lookX: number
lookY: number
}
brows: BrowCapture
mouth: MouthCapture
}
export type ObservedExpression = {
id: 'neutral' | 'smile' | 'laugh' | 'angry' | 'surprised' | 'sad'
label: string
confidence: number
scores: {
smile: number
laugh: number
angry: number
surprised: number
sad: number
}
}
export type RetargetedCapture = {
expression: Expression
blinkAmount: number
signals: {
lookX: number
lookY: number
blinkLeft: number
blinkRight: number
positionX: number
positionY: number
positionZ: number
}
}
const degrees = (radians: number) => (radians * 180) / Math.PI
const score = (scores: Readonly<Record<string, number>>, name: string) => scores[name] ?? 0
export const headCaptureFromMatrix = (matrix: readonly number[]): HeadCapture => {
if (matrix.length < 11) return { pitch: 0, yaw: 0, roll: 0 }
const horizontalScale = Math.hypot(matrix[0], matrix[4])
const singular = horizontalScale < 0.000001
const pitch = singular ? Math.atan2(-matrix[6], matrix[5]) : Math.atan2(matrix[9], matrix[10])
const yaw = Math.atan2(-matrix[8], horizontalScale)
const roll = singular ? 0 : Math.atan2(matrix[4], matrix[0])
return { pitch: degrees(pitch), yaw: degrees(yaw), roll: degrees(roll) }
}
export const positionCaptureFromMatrix = (matrix: readonly number[]): PositionCapture => ({
x: matrix[12] ?? 0,
y: matrix[13] ?? 0,
z: matrix[14] ?? 0,
})
export const captureFrameFromMediaPipe = (
scores: Readonly<Record<string, number>>,
matrix: readonly number[],
timestamp: number
): FacialCaptureFrame => {
const lookRight = (score(scores, 'eyeLookOutLeft') + score(scores, 'eyeLookInRight')) / 2
const lookLeft = (score(scores, 'eyeLookInLeft') + score(scores, 'eyeLookOutRight')) / 2
const lookUp = (score(scores, 'eyeLookUpLeft') + score(scores, 'eyeLookUpRight')) / 2
const lookDown = (score(scores, 'eyeLookDownLeft') + score(scores, 'eyeLookDownRight')) / 2
return {
timestamp,
head: headCaptureFromMatrix(matrix),
position: positionCaptureFromMatrix(matrix),
eyes: {
left: {
blink: score(scores, 'eyeBlinkLeft'),
wide: score(scores, 'eyeWideLeft'),
squint: score(scores, 'eyeSquintLeft'),
},
right: {
blink: score(scores, 'eyeBlinkRight'),
wide: score(scores, 'eyeWideRight'),
squint: score(scores, 'eyeSquintRight'),
},
lookX: clamp(lookRight - lookLeft, -1, 1),
lookY: clamp(lookUp - lookDown, -1, 1),
},
brows: {
downLeft: score(scores, 'browDownLeft'),
downRight: score(scores, 'browDownRight'),
innerUp: score(scores, 'browInnerUp'),
outerUpLeft: score(scores, 'browOuterUpLeft'),
outerUpRight: score(scores, 'browOuterUpRight'),
},
mouth: {
jawOpen: score(scores, 'jawOpen'),
smileLeft: score(scores, 'mouthSmileLeft'),
smileRight: score(scores, 'mouthSmileRight'),
frownLeft: score(scores, 'mouthFrownLeft'),
frownRight: score(scores, 'mouthFrownRight'),
pucker: score(scores, 'mouthPucker'),
funnel: score(scores, 'mouthFunnel'),
left: score(scores, 'mouthLeft'),
right: score(scores, 'mouthRight'),
pressLeft: score(scores, 'mouthPressLeft'),
pressRight: score(scores, 'mouthPressRight'),
},
}
}
const mix = (from: number, to: number, amount: number) => from + (to - from) * amount
const mixEye = (from: EyeCapture, to: EyeCapture, amount: number): EyeCapture => ({
blink: mix(from.blink, to.blink, amount),
wide: mix(from.wide, to.wide, amount),
squint: mix(from.squint, to.squint, amount),
})
const mixBrows = (from: BrowCapture, to: BrowCapture, amount: number): BrowCapture => ({
downLeft: mix(from.downLeft, to.downLeft, amount),
downRight: mix(from.downRight, to.downRight, amount),
innerUp: mix(from.innerUp, to.innerUp, amount),
outerUpLeft: mix(from.outerUpLeft, to.outerUpLeft, amount),
outerUpRight: mix(from.outerUpRight, to.outerUpRight, amount),
})
const mixMouth = (from: MouthCapture, to: MouthCapture, amount: number): MouthCapture => ({
jawOpen: mix(from.jawOpen, to.jawOpen, amount),
smileLeft: mix(from.smileLeft, to.smileLeft, amount),
smileRight: mix(from.smileRight, to.smileRight, amount),
frownLeft: mix(from.frownLeft, to.frownLeft, amount),
frownRight: mix(from.frownRight, to.frownRight, amount),
pucker: mix(from.pucker, to.pucker, amount),
funnel: mix(from.funnel, to.funnel, amount),
left: mix(from.left, to.left, amount),
right: mix(from.right, to.right, amount),
pressLeft: mix(from.pressLeft, to.pressLeft, amount),
pressRight: mix(from.pressRight, to.pressRight, amount),
})
const average = (left: number, right: number) => (left + right) / 2
export const observeExpression = (frame: FacialCaptureFrame): ObservedExpression => {
const smile = average(frame.mouth.smileLeft, frame.mouth.smileRight)
const frown = average(frame.mouth.frownLeft, frame.mouth.frownRight)
const mouthPress = average(frame.mouth.pressLeft, frame.mouth.pressRight)
const eyeSquint = average(frame.eyes.left.squint, frame.eyes.right.squint)
const eyeWide = average(frame.eyes.left.wide, frame.eyes.right.wide)
const browDown = average(frame.brows.downLeft, frame.brows.downRight)
const browOuterUp = average(frame.brows.outerUpLeft, frame.brows.outerUpRight)
const jawOpen = frame.mouth.jawOpen
const scores = {
smile: clamp(smile * 0.78 + eyeSquint * 0.22, 0, 1),
laugh: clamp(smile * 0.5 + jawOpen * 0.35 + eyeSquint * 0.15, 0, 1),
angry: clamp(browDown * 0.5 + eyeSquint * 0.2 + mouthPress * 0.3, 0, 1),
surprised: clamp(
browOuterUp * 0.3 + frame.brows.innerUp * 0.2 + eyeWide * 0.25 + jawOpen * 0.25,
0,
1
),
sad: clamp(frame.brows.innerUp * 0.45 + frown * 0.55, 0, 1),
}
const candidates = [
{ id: 'smile' as const, label: 'Smile', confidence: scores.smile },
{ id: 'laugh' as const, label: 'Laugh-like', confidence: scores.laugh },
{ id: 'angry' as const, label: 'Angry-like', confidence: scores.angry },
{ id: 'surprised' as const, label: 'Surprised-like', confidence: scores.surprised },
{ id: 'sad' as const, label: 'Sad-like', confidence: scores.sad },
].sort((left, right) => right.confidence - left.confidence)
const strongest = candidates[0]
if (!strongest || strongest.confidence < 0.3) {
return {
id: 'neutral',
label: 'Neutral / unclear',
confidence: 1 - (strongest?.confidence ?? 0),
scores,
}
}
return { ...strongest, scores }
}
export const mirrorCaptureFrame = (frame: FacialCaptureFrame): FacialCaptureFrame => ({
...frame,
head: {
pitch: frame.head.pitch,
yaw: -frame.head.yaw,
// The renderer's Z axis already compensates for SVG's downward Y axis.
// Keeping MediaPipe's roll here avoids mirroring that rotation twice.
roll: frame.head.roll,
},
position: {
x: -frame.position.x,
y: frame.position.y,
z: frame.position.z,
},
eyes: {
...frame.eyes,
lookX: -frame.eyes.lookX,
},
})
export const createFacialCaptureSmoother = (initialSmoothing = 0.58) => {
let previous: FacialCaptureFrame | null = null
let smoothing = clamp(initialSmoothing, 0, 1)
return {
next(frame: FacialCaptureFrame) {
if (!previous) {
previous = frame
return frame
}
const elapsed = clamp(frame.timestamp - previous.timestamp, 1, 100)
const smoothingCurve = smoothing ** 2
const headResponse = mix(18, 298, smoothingCurve)
const featureResponse = mix(12, 122, smoothingCurve)
const headAmount = 1 - Math.exp(-elapsed / headResponse)
const featureAmount = 1 - Math.exp(-elapsed / featureResponse)
const next: FacialCaptureFrame = {
timestamp: frame.timestamp,
head: {
pitch: mix(previous.head.pitch, frame.head.pitch, headAmount),
yaw: mix(previous.head.yaw, frame.head.yaw, headAmount),
roll: mix(previous.head.roll, frame.head.roll, headAmount),
},
position: {
x: mix(previous.position.x, frame.position.x, headAmount),
y: mix(previous.position.y, frame.position.y, headAmount),
z: mix(previous.position.z, frame.position.z, headAmount),
},
eyes: {
left: mixEye(previous.eyes.left, frame.eyes.left, featureAmount),
right: mixEye(previous.eyes.right, frame.eyes.right, featureAmount),
lookX: mix(previous.eyes.lookX, frame.eyes.lookX, featureAmount),
lookY: mix(previous.eyes.lookY, frame.eyes.lookY, featureAmount),
},
brows: mixBrows(previous.brows, frame.brows, featureAmount),
mouth: mixMouth(previous.mouth, frame.mouth, featureAmount),
}
previous = next
return next
},
reset() {
previous = null
},
setSmoothing(nextSmoothing: number) {
smoothing = clamp(nextSmoothing, 0, 1)
},
}
}
export const retargetCaptureFrame = (
frame: FacialCaptureFrame,
neutral: FacialCaptureFrame,
base: Expression
): RetargetedCapture => {
const blinkLeft = clamp((frame.eyes.left.blink - neutral.eyes.left.blink) * 1.65, 0, 1)
const blinkRight = clamp((frame.eyes.right.blink - neutral.eyes.right.blink) * 1.65, 0, 1)
const leftWide = frame.eyes.left.wide - neutral.eyes.left.wide
const rightWide = frame.eyes.right.wide - neutral.eyes.right.wide
const leftSquint = frame.eyes.left.squint - neutral.eyes.left.squint
const rightSquint = frame.eyes.right.squint - neutral.eyes.right.squint
const lookX = clamp((frame.eyes.lookX - neutral.eyes.lookX) * 1.35, -1, 1)
const lookY = clamp((frame.eyes.lookY - neutral.eyes.lookY) * 1.35, -1, 1)
const positionX = clamp((frame.position.x - neutral.position.x) * 5, -55, 55)
const positionY = clamp((frame.position.y - neutral.position.y) * -5, -55, 55)
const positionZ = clamp((frame.position.z - neutral.position.z) * 0.035, -0.38, 0.48)
const expression: Expression = {
...base,
headX: base.headX + clamp(frame.head.pitch - neutral.head.pitch, -38, 38),
headY: base.headY - clamp(frame.head.yaw - neutral.head.yaw, -52, 52),
headZ: base.headZ - clamp(frame.head.roll - neutral.head.roll, -42, 42),
widthLeft: clamp(base.widthLeft * (1 - leftSquint * 0.16), 10, 100),
widthRight: clamp(base.widthRight * (1 - rightSquint * 0.16), 10, 100),
heightLeft: clamp(base.heightLeft * (1 + leftWide * 0.72 - leftSquint * 0.32), 10, 100),
heightRight: clamp(base.heightRight * (1 + rightWide * 0.72 - rightSquint * 0.32), 10, 100),
positionXLeft: base.positionXLeft + lookX * 7.5,
positionXRight: base.positionXRight + lookX * 7.5,
positionYLeft: base.positionYLeft - lookY * 6,
positionYRight: base.positionYRight - lookY * 6,
}
return {
expression,
blinkAmount: clamp(1 - Math.max(blinkLeft, blinkRight), 0.035, 1),
signals: { lookX, lookY, blinkLeft, blinkRight, positionX, positionY, positionZ },
}
}

View File

@ -0,0 +1,191 @@
import { clamp } from '@/features/avatar/geometry'
export type MotionSample = {
time: number
pitch: number
yaw: number
roll: number
positionX: number
positionY: number
positionZ: number
lookX: number
lookY: number
blink: number
}
export type MotionTake = {
duration: number
samples: MotionSample[]
}
export type MotionTakeSettings = {
smoothing: number
detailRetention: number
amplitude: number
speed: number
}
export const defaultMotionTakeSettings: MotionTakeSettings = {
smoothing: 0.62,
detailRetention: 0.32,
amplitude: 1,
speed: 1,
}
export const motionChannels = [
'pitch',
'yaw',
'roll',
'positionX',
'positionY',
'positionZ',
'lookX',
'lookY',
'blink',
] as const
export type MotionChannel = (typeof motionChannels)[number]
const mix = (from: number, to: number, amount: number) => from + (to - from) * amount
const dot = (left: readonly number[], right: readonly number[]) =>
left.reduce((sum, value, index) => sum + value * right[index], 0)
const applyCurvatureSystem = (values: readonly number[], lambda: number) => {
const result = [...values]
for (let index = 0; index < values.length - 2; index += 1) {
const secondDifference = values[index] - 2 * values[index + 1] + values[index + 2]
result[index] += lambda * secondDifference
result[index + 1] -= lambda * 2 * secondDifference
result[index + 2] += lambda * secondDifference
}
return result
}
const curvatureDiagonal = (length: number, lambda: number) =>
Array.from({ length }, (_, index) => {
const penalty =
index === 0 || index === length - 1 ? 1 : index === 1 || index === length - 2 ? 5 : 6
return 1 + lambda * penalty
})
const solvePenalizedCurve = (values: readonly number[], lambda: number) => {
if (values.length < 3 || lambda <= 0) return [...values]
let solution = [...values]
const initialProjection = applyCurvatureSystem(solution, lambda)
let residual = values.map((value, index) => value - initialProjection[index])
const diagonal = curvatureDiagonal(values.length, lambda)
let preconditioned = residual.map((value, index) => value / diagonal[index])
let direction = [...preconditioned]
let residualScale = dot(residual, preconditioned)
const tolerance = Math.max(1e-10, dot(values, values) * 1e-12)
for (let iteration = 0; iteration < Math.min(120, values.length * 2); iteration += 1) {
const projected = applyCurvatureSystem(direction, lambda)
const denominator = dot(direction, projected)
if (Math.abs(denominator) < 1e-12) break
const amount = residualScale / denominator
solution = solution.map((value, index) => value + amount * direction[index])
residual = residual.map((value, index) => value - amount * projected[index])
if (dot(residual, residual) <= tolerance) break
preconditioned = residual.map((value, index) => value / diagonal[index])
const nextScale = dot(residual, preconditioned)
const directionScale = nextScale / residualScale
direction = preconditioned.map((value, index) => value + directionScale * direction[index])
residualScale = nextScale
}
const startCorrection = values[0] - solution[0]
const endCorrection = values.at(-1)! - solution.at(-1)!
return solution.map(
(value, index) =>
value + mix(startCorrection, endCorrection, index / Math.max(1, solution.length - 1))
)
}
const linearSample = (take: MotionTake, time: number): MotionSample | null => {
if (take.samples.length === 0) return null
const target = clamp(time, 0, take.duration)
const exact = take.samples.findIndex(sample => sample.time >= target)
if (exact <= 0) return take.samples[0]
const to = take.samples[exact]
const from = take.samples[exact - 1]
const amount = clamp((target - from.time) / Math.max(1, to.time - from.time), 0, 1)
const sample = { ...from, time: target }
motionChannels.forEach(channel => {
sample[channel] = mix(from[channel], to[channel], amount)
})
return sample
}
const resampleTake = (take: MotionTake): MotionTake => {
if (take.samples.length < 2 || take.duration <= 0) return take
const intervals = take.samples
.slice(1)
.map((sample, index) => sample.time - take.samples[index].time)
.filter(interval => interval > 0)
.sort((left, right) => left - right)
const medianInterval = intervals[Math.floor(intervals.length / 2)] ?? 1000 / 30
const targetInterval = clamp(medianInterval, 1000 / 60, 1000 / 24)
const sampleCount = Math.max(2, Math.round(take.duration / targetInterval) + 1)
const interval = take.duration / (sampleCount - 1)
const samples = Array.from({ length: sampleCount }, (_, index) =>
linearSample(take, index * interval)
).filter((sample): sample is MotionSample => sample !== null)
return { duration: take.duration, samples }
}
export const processMotionTake = (take: MotionTake, settings: MotionTakeSettings): MotionTake => {
if (take.samples.length === 0) return { duration: 0, samples: [] }
const resampled = resampleTake(take)
const smoothing = clamp(settings.smoothing, 0, 1)
const detailRetention = clamp(settings.detailRetention, 0, 1)
const amplitude = clamp(settings.amplitude, 0, 2)
const speed = clamp(settings.speed, 0.25, 3)
const lambda = (10 ** (smoothing * 3) - 1) * 0.45
const refinedChannels = Object.fromEntries(
motionChannels.map(channel => {
const raw = resampled.samples.map(sample => sample[channel])
const rounded = solvePenalizedCurve(raw, lambda)
return [channel, rounded.map((value, index) => mix(value, raw[index], detailRetention))]
})
) as Record<MotionChannel, number[]>
const samples = resampled.samples.map((sample, index) => {
const processed = { ...sample, time: sample.time / speed }
motionChannels.forEach(channel => {
const refined = refinedChannels[channel][index]
processed[channel] =
channel === 'blink' ? clamp(refined * amplitude, 0, 1) : refined * amplitude
})
return processed
})
return { duration: take.duration / speed, samples }
}
export const sampleMotionTake = (take: MotionTake, time: number): MotionSample | null => {
if (take.samples.length === 0) return null
const target = clamp(time, 0, take.duration)
const exact = take.samples.findIndex(sample => sample.time >= target)
if (exact <= 0) return take.samples[0]
const to = take.samples[exact]
const from = take.samples[exact - 1]
const amount = clamp((target - from.time) / Math.max(1, to.time - from.time), 0, 1)
const before = take.samples[Math.max(0, exact - 2)]
const after = take.samples[Math.min(take.samples.length - 1, exact + 1)]
const sample = { ...from, time: target }
motionChannels.forEach(channel => {
const p0 = before[channel]
const p1 = from[channel]
const p2 = to[channel]
const p3 = after[channel]
const squared = amount * amount
const cubed = squared * amount
sample[channel] =
0.5 *
(2 * p1 +
(-p0 + p2) * amount +
(2 * p0 - 5 * p1 + 4 * p2 - p3) * squared +
(-p0 + 3 * p1 - 3 * p2 + p3) * cubed)
})
sample.blink = clamp(sample.blink, 0, 1)
return sample
}

View File

@ -0,0 +1,413 @@
import { ArrowLeft, Eye, Sparkles } from 'lucide-react'
import {
animate,
useMotionValue,
useMotionValueEvent,
useReducedMotion,
type AnimationPlaybackControls,
} from 'motion/react'
import {
StrictMode,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from 'react'
import { createRoot } from 'react-dom/client'
import { interpolateHexColor, poseWithAvatarEyes, resolveColors } from '@/app/studio-utils'
import { resolveAvatarBehavior } from '@/features/avatar/avatars'
import {
interpolatePose,
poseFromExpression,
renderAvatar,
rotateExpressionWithArcball,
type AvatarPose,
} from '@/features/avatar/geometry'
import {
advanceLegacyGrokSpring,
comparisonBlinkDelay,
interpolateLegacyGrokExpression,
legacyGrokSpringIsSettled,
} from '@/features/comparison/comparisonTransition'
import {
LEGACY_GROK_BODY_PATH,
legacyGrokExpressions,
legacyGrokPath,
type LegacyGrokExpression,
} from '@/features/comparison/legacyGrok'
import { loadStudioDocument } from '@/features/studio/studioDocument'
import './grokComparison.css'
const legacyRingCentroid = (ring: LegacyGrokExpression[number]) => {
const total = ring.reduce((sum, point) => [sum[0] + point[0], sum[1] + point[1]] as const, [
0, 0,
] as const)
return [total[0] / ring.length, total[1] / ring.length] as const
}
function LegacyGrok({
expression,
index,
blinkAmount,
}: {
expression: LegacyGrokExpression
index: number
blinkAmount: number
}) {
return (
<svg
className="comparison-avatar legacy-avatar"
viewBox="-25.87 -25.91 280.28 280.28"
role="img"
>
<title>Official Grok Bot, expression {index}</title>
<defs>
<clipPath id="legacy-comparison-head-clip">
<path d={LEGACY_GROK_BODY_PATH} />
</clipPath>
</defs>
<g>
<path className="legacy-body" d={LEGACY_GROK_BODY_PATH} />
<g clipPath="url(#legacy-comparison-head-clip)">
{expression.map((ring, eyeIndex) => {
const center = legacyRingCentroid(ring)
return (
<path
className="legacy-eye"
d={legacyGrokPath(ring)}
transform={`translate(${center[0]} ${center[1]}) scale(1 ${blinkAmount}) translate(${-center[0]} ${-center[1]})`}
key={eyeIndex}
/>
)
})}
</g>
</g>
</svg>
)
}
function GrokComparisonApp() {
const [studioDocument] = useState(() => loadStudioDocument())
const [selectedExpression, setSelectedExpression] = useState(0)
const [blinkAmount, setBlinkAmount] = useState(1)
const blinkValue = useMotionValue(1)
const rotationProgress = useMotionValue(0)
useMotionValueEvent(blinkValue, 'change', setBlinkAmount)
const reduceMotion = useReducedMotion()
const grokAvatar = studioDocument.library.avatars.find(
avatar => avatar.name.trim().toLowerCase() === 'grok bot'
)
if (!grokAvatar) {
return (
<main className="comparison-error">
<strong>Grok Bot could not be found.</strong>
<a href="../">Return to Avatar Lab</a>
</main>
)
}
const behavior = resolveAvatarBehavior(grokAvatar, {
expressions: studioDocument.expressions,
sequences: studioDocument.sequences,
})
const expressionCount = Math.min(behavior.expressions.length, legacyGrokExpressions.length)
const activeIndex = Math.min(selectedExpression, expressionCount - 1)
const [displayedPose, setDisplayedPose] = useState<AvatarPose>(() =>
poseWithAvatarEyes(behavior.expressions[0], grokAvatar.eyes)
)
const [displayedLegacyExpression, setDisplayedLegacyExpression] = useState<LegacyGrokExpression>(
() => legacyGrokExpressions[0]
)
const displayedPoseRef = useRef(displayedPose)
const displayedLegacyRef = useRef(displayedLegacyExpression)
const comparisonFrame = useRef<number | null>(null)
const blinkAnimation = useRef<AnimationPlaybackControls | null>(null)
const rotationAnimation = useRef<AnimationPlaybackControls | null>(null)
const rotationDrag = useRef<{
startPoint: readonly [number, number]
pose: AvatarPose
} | null>(null)
const expression = displayedPose.expression
const geometry = renderAvatar(displayedPose, grokAvatar.body.primary, blinkAmount, {
includeWire: false,
bodyNodes: grokAvatar.body.nodes,
})
const colors = resolveColors(expression, grokAvatar.colors)
useEffect(
() => () => {
if (comparisonFrame.current !== null) cancelAnimationFrame(comparisonFrame.current)
blinkAnimation.current?.stop()
rotationAnimation.current?.stop()
},
[]
)
const toAvatarPoint = (event: ReactPointerEvent<SVGPathElement>): readonly [number, number] => {
const rectangle = event.currentTarget.ownerSVGElement!.getBoundingClientRect()
return [
((event.clientX - rectangle.left) / rectangle.width) * 300 - 150,
((event.clientY - rectangle.top) / rectangle.height) * 300 - 150,
]
}
const animateDisplayedRotation = (target: AvatarPose, duration: number) => {
rotationAnimation.current?.stop()
const from = displayedPoseRef.current
if (reduceMotion) {
displayedPoseRef.current = target
setDisplayedPose(target)
return
}
rotationProgress.jump(0)
rotationAnimation.current = animate(rotationProgress, 1, {
duration,
ease: [0.22, 1, 0.36, 1],
onUpdate: progress => {
const pose = interpolatePose(from, target, progress)
displayedPoseRef.current = pose
setDisplayedPose(pose)
},
onComplete: () => {
displayedPoseRef.current = target
setDisplayedPose(target)
},
})
}
const startRotation = (event: ReactPointerEvent<SVGPathElement>) => {
event.preventDefault()
if (comparisonFrame.current !== null) {
cancelAnimationFrame(comparisonFrame.current)
comparisonFrame.current = null
}
rotationAnimation.current?.stop()
rotationDrag.current = {
startPoint: toAvatarPoint(event),
pose: displayedPoseRef.current,
}
event.currentTarget.setPointerCapture(event.pointerId)
}
const moveRotation = (event: ReactPointerEvent<SVGPathElement>) => {
if (!rotationDrag.current) return
const rotated = rotateExpressionWithArcball(
rotationDrag.current.pose.expression,
rotationDrag.current.startPoint,
toAvatarPoint(event)
)
animateDisplayedRotation(poseFromExpression(rotated), 0.1)
}
const finishRotation = () => {
if (!rotationDrag.current) return
rotationDrag.current = null
animateDisplayedRotation(
poseWithAvatarEyes(behavior.expressions[activeIndex], grokAvatar.eyes),
0.48
)
}
const blink = () => {
blinkAnimation.current?.stop()
blinkValue.jump(1)
blinkAnimation.current = animate(blinkValue, [1, 0.04, 1], {
duration: 0.32,
times: [0, 0.42, 1],
ease: ['easeIn', 'easeOut'],
onUpdate: setBlinkAmount,
})
}
useEffect(() => {
let timer: number | null = null
let active = true
const scheduleBlink = () => {
timer = window.setTimeout(() => {
if (!active) return
blink()
scheduleBlink()
}, comparisonBlinkDelay())
}
scheduleBlink()
return () => {
active = false
if (timer !== null) window.clearTimeout(timer)
}
}, [])
const selectExpression = (index: number) => {
if (index === activeIndex) return
const nextPose = poseWithAvatarEyes(behavior.expressions[index], grokAvatar.eyes)
const nextLegacyExpression = legacyGrokExpressions[index]
setSelectedExpression(index)
if (comparisonFrame.current !== null) cancelAnimationFrame(comparisonFrame.current)
rotationDrag.current = null
rotationAnimation.current?.stop()
if (reduceMotion) {
displayedPoseRef.current = nextPose
displayedLegacyRef.current = nextLegacyExpression
setDisplayedPose(nextPose)
setDisplayedLegacyExpression(nextLegacyExpression)
return
}
const fromPose = displayedPoseRef.current
const fromColors = resolveColors(fromPose.expression, grokAvatar.colors)
const targetColors = resolveColors(nextPose.expression, grokAvatar.colors)
const fromLegacyExpression = displayedLegacyRef.current
let spring = { morph: 0, velocity: 0 }
let previousTime = performance.now()
const tickComparison = (time: number) => {
spring = advanceLegacyGrokSpring(spring, (time - previousTime) / 1000)
previousTime = time
const progress = Math.max(0, Math.min(1, spring.morph))
const pose = interpolatePose(fromPose, nextPose, progress)
pose.expression.bodyColor = interpolateHexColor(fromColors.body, targetColors.body, progress)
pose.expression.eyeColor = interpolateHexColor(fromColors.eyes, targetColors.eyes, progress)
const legacyExpression = interpolateLegacyGrokExpression(
fromLegacyExpression,
nextLegacyExpression,
progress
)
displayedPoseRef.current = pose
displayedLegacyRef.current = legacyExpression
setDisplayedPose(pose)
setDisplayedLegacyExpression(legacyExpression)
if (!legacyGrokSpringIsSettled(spring)) {
comparisonFrame.current = requestAnimationFrame(tickComparison)
return
}
comparisonFrame.current = null
displayedPoseRef.current = nextPose
displayedLegacyRef.current = nextLegacyExpression
setDisplayedPose(nextPose)
setDisplayedLegacyExpression(nextLegacyExpression)
}
comparisonFrame.current = requestAnimationFrame(tickComparison)
}
return (
<main className="comparison-page">
<header className="comparison-header">
<a className="comparison-back" href="../">
<ArrowLeft aria-hidden="true" />
Avatar Lab
</a>
<div className="comparison-header-actions">
<div className="comparison-signal">
<Sparkles aria-hidden="true" />
Expression {String(activeIndex).padStart(2, '0')}
</div>
</div>
</header>
<section className="comparison-stage" aria-label="Comparison of both Grok Bots">
<article className="comparison-card remastered-card">
<div className="comparison-card-heading">
<div>
<h2>Procedural 3D engine</h2>
<p>
Each expression interpolates a three-dimensional pose. The engine rebuilds and
projects the body and eyes as SVG on every frame.
</p>
</div>
</div>
<div className="comparison-avatar-frame">
<svg className="comparison-avatar" viewBox="-150 -150 300 300" role="img">
<title>Grok Bot remastered, expression {activeIndex}</title>
<defs>
<clipPath id="remastered-comparison-head-clip">
<path d={geometry.headPath} />
</clipPath>
</defs>
<g>
{geometry.backPaths.map((path, index) => (
<path d={path} fill={colors.body} key={`back-${index}`} />
))}
<path d={geometry.headPath} fill={colors.body} />
<g clipPath="url(#remastered-comparison-head-clip)" fill={colors.eyes}>
{geometry.leftVisible && <path d={geometry.leftPath} />}
{geometry.rightVisible && <path d={geometry.rightPath} />}
</g>
{geometry.frontPaths.map((path, index) => (
<path d={path} fill={colors.body} key={`front-${index}`} />
))}
<path
className="comparison-rotation-hitarea"
d={geometry.headPath}
onPointerDown={startRotation}
onPointerMove={moveRotation}
onPointerUp={finishRotation}
onPointerCancel={finishRotation}
/>
</g>
</svg>
</div>
</article>
<div className="comparison-versus" aria-hidden="true">
VS
</div>
<article className="comparison-card legacy-card">
<div className="comparison-card-heading">
<div>
<h2>SVG morphing</h2>
<p>
Each transition morphs the 48 eye points between 25 fixed expressions. The body
remains a single, unchanged SVG path.
</p>
</div>
</div>
<div className="comparison-avatar-frame">
<LegacyGrok
expression={displayedLegacyExpression}
index={activeIndex}
blinkAmount={blinkAmount}
/>
</div>
</article>
</section>
<section className="comparison-expression-panel" aria-label="Official expressions">
<div className="comparison-expression-actions">
<button className="comparison-blink-button" type="button" onClick={blink}>
<Eye aria-hidden="true" />
Blink
</button>
</div>
<div className="comparison-expression-grid">
{legacyGrokExpressions.slice(0, expressionCount).map((legacyExpression, index) => (
<button
className="comparison-expression-button"
type="button"
aria-label={`Expression ${index}`}
aria-pressed={index === activeIndex}
onClick={() => selectExpression(index)}
key={index}
>
<svg viewBox="0 0 229 229" aria-hidden="true">
{legacyExpression.map((ring, eyeIndex) => (
<path d={legacyGrokPath(ring)} key={eyeIndex} />
))}
</svg>
<span>{String(index).padStart(2, '0')}</span>
</button>
))}
</div>
</section>
</main>
)
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<GrokComparisonApp />
</StrictMode>
)

View File

@ -0,0 +1,52 @@
import { legacyGrokExpressions, legacyGrokPath } from '@/features/comparison/legacyGrok'
import {
advanceLegacyGrokSpring,
comparisonBlinkDelay,
interpolateLegacyGrokExpression,
legacyGrokSpringIsSettled,
} from '@/features/comparison/comparisonTransition'
describe('legacy Grok source', () => {
it('extracts every official expression from the legacy prototype', () => {
expect(legacyGrokExpressions).toHaveLength(25)
legacyGrokExpressions.forEach(expression => {
expect(expression).toHaveLength(2)
expect(expression[0].length).toBeGreaterThan(40)
expect(expression[1].length).toBeGreaterThan(40)
})
})
it('turns a legacy ring into a closed SVG path', () => {
const path = legacyGrokPath(legacyGrokExpressions[0][0])
expect(path).toMatch(/^M/)
expect(path).toMatch(/Z$/)
})
})
describe('legacy Grok interpolation', () => {
it('morphs every historic point between two expressions', () => {
const from = legacyGrokExpressions[0]
const to = legacyGrokExpressions[1]
const halfway = interpolateLegacyGrokExpression(from, to, 0.5)
expect(halfway[0][0][0]).toBeCloseTo((from[0][0][0] + to[0][0][0]) / 2)
expect(halfway[1][20][1]).toBeCloseTo((from[1][20][1] + to[1][20][1]) / 2)
})
it('reproduces the legacy critically damped spring until it settles', () => {
let spring = { morph: 0, velocity: 0 }
for (let frame = 0; frame < 240; frame += 1) {
spring = advanceLegacyGrokSpring(spring, 1 / 60)
}
expect(spring.morph).toBeCloseTo(1, 3)
expect(legacyGrokSpringIsSettled(spring)).toBe(true)
})
it('schedules automatic blinks inside the shared random interval', () => {
expect(comparisonBlinkDelay(() => 0)).toBe(2400)
expect(comparisonBlinkDelay(() => 0.5)).toBe(3800)
expect(comparisonBlinkDelay(() => 1)).toBe(5200)
})
})

View File

@ -0,0 +1,45 @@
import type { LegacyGrokExpression } from '@/features/comparison/legacyGrok'
export type LegacyGrokSpring = {
morph: number
velocity: number
}
const clamp01 = (value: number) => Math.max(0, Math.min(1, value))
export const interpolateLegacyGrokExpression = (
from: LegacyGrokExpression,
to: LegacyGrokExpression,
progress: number
): LegacyGrokExpression => {
const boundedProgress = clamp01(progress)
return from.map((ring, eyeIndex) =>
ring.map((point, pointIndex) => {
const target = to[eyeIndex][pointIndex]
return [
point[0] + (target[0] - point[0]) * boundedProgress,
point[1] + (target[1] - point[1]) * boundedProgress,
] as const
})
) as unknown as LegacyGrokExpression
}
export const advanceLegacyGrokSpring = (
spring: LegacyGrokSpring,
deltaSeconds: number,
frequency = 7
): LegacyGrokSpring => {
const delta = Math.min(Math.max(deltaSeconds, 0), 0.1)
const velocity =
spring.velocity +
(-2 * frequency * spring.velocity - frequency * frequency * (spring.morph - 1)) * delta
const morph = spring.morph + velocity * delta
if (!Number.isFinite(morph) || !Number.isFinite(velocity)) return { morph: 1, velocity: 0 }
return { morph, velocity }
}
export const legacyGrokSpringIsSettled = ({ morph, velocity }: LegacyGrokSpring) =>
Math.abs(1 - morph) < 0.001 && Math.abs(velocity) < 0.001
export const comparisonBlinkDelay = (random = Math.random) => 2400 + random() * 2800

View File

@ -0,0 +1,461 @@
@import 'tailwindcss';
:root {
color: #f5f4ed;
background: #090a0c;
font-family: Georgia, 'Times New Roman', serif;
font-synthesis: none;
}
* {
box-sizing: border-box;
user-select: none;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
background:
linear-gradient(rgb(255 255 255 / 2%) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 2%) 1px, transparent 1px),
radial-gradient(circle at 50% 8%, #25272c 0, #111216 34%, #090a0c 70%);
background-size:
32px 32px,
32px 32px,
auto;
}
button,
a {
font: inherit;
}
.comparison-page {
width: min(1440px, 100%);
min-height: 100vh;
margin: 0 auto;
padding: 34px clamp(18px, 4vw, 64px) 72px;
}
.comparison-header {
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
gap: 28px;
padding-bottom: 32px;
border-bottom: 1px solid rgb(255 255 255 / 14%);
}
.comparison-back,
.comparison-signal,
.comparison-blink-button,
.comparison-kicker,
.comparison-edition,
.comparison-engine-tag,
.comparison-card footer,
.comparison-expression-button span {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.comparison-back {
display: inline-flex;
width: fit-content;
align-items: center;
gap: 9px;
padding: 9px 0;
color: #969ba7;
font-size: 11px;
text-decoration: none;
transition: color 160ms ease;
}
.comparison-back:hover {
color: white;
}
.comparison-back svg,
.comparison-signal svg,
.comparison-blink-button svg {
width: 15px;
height: 15px;
}
.comparison-title-lockup {
max-width: 760px;
margin-inline: auto;
text-align: center;
}
.comparison-kicker,
.comparison-edition {
color: #6f8df1;
font-size: 14px;
font-weight: 800;
}
.comparison-title-lockup h1 {
margin: 13px 0 16px;
font-size: clamp(42px, 6.5vw, 92px);
font-weight: 400;
letter-spacing: -0.065em;
line-height: 0.86;
}
.comparison-title-lockup h1 em {
display: block;
color: #7f8490;
font-weight: 400;
}
.comparison-title-lockup p {
max-width: 600px;
margin: 0 auto;
color: #9b9fa8;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 14px;
line-height: 1.65;
}
.comparison-signal {
display: inline-flex;
justify-self: end;
align-items: center;
gap: 8px;
padding: 9px 11px;
border: 1px solid rgb(111 141 241 / 40%);
border-radius: 999px;
color: #b9c7fb;
background: rgb(111 141 241 / 10%);
font-size: 10px;
}
.comparison-header-actions {
display: grid;
justify-items: end;
gap: 8px;
}
.comparison-blink-button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 12px;
border: 1px solid rgb(255 255 255 / 18%);
border-radius: 999px;
color: #a6abb6;
background: rgb(255 255 255 / 5%);
font-size: 10px;
cursor: pointer;
transition:
color 160ms ease,
border-color 160ms ease,
background 160ms ease;
}
.comparison-blink-button:hover,
.comparison-blink-button:focus-visible {
border-color: rgb(255 255 255 / 42%);
color: white;
background: rgb(255 255 255 / 10%);
outline: none;
}
.comparison-stage {
position: relative;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: clamp(14px, 2vw, 28px);
padding: 34px 0;
}
.comparison-card {
min-width: 0;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 14%);
border-radius: 26px;
background: #111318;
box-shadow: 0 28px 70px rgb(0 0 0 / 28%);
}
.remastered-card,
.legacy-card {
background: #fff;
color: #111216;
}
.comparison-card-heading {
display: flex;
min-height: 112px;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 22px 24px 18px;
border-bottom: 1px solid rgb(0 0 0 / 12%);
}
.comparison-card-heading > div {
max-width: 560px;
}
.legacy-card .comparison-card-heading {
border-bottom-color: rgb(0 0 0 / 12%);
}
.comparison-card h2 {
margin: 0 0 8px;
color: #000;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
font-size: 18px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.comparison-card-heading p {
max-width: 54ch;
margin: 0;
color: #555861;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 15px;
line-height: 1.5;
}
.legacy-card .comparison-card-heading h2 {
color: #51545d;
}
.legacy-card .comparison-card-heading p {
color: #555861;
}
.comparison-avatar-frame {
display: grid;
min-height: min(49vw, 610px);
place-items: center;
padding: clamp(18px, 3vw, 44px);
}
.comparison-avatar {
display: block;
width: min(100%, 540px);
aspect-ratio: 1;
filter: drop-shadow(0 26px 35px rgb(0 0 0 / 28%));
}
.comparison-rotation-hitarea {
fill: transparent;
cursor: grab;
touch-action: none;
}
.comparison-rotation-hitarea:active {
cursor: grabbing;
}
.legacy-avatar {
filter: drop-shadow(0 26px 32px rgb(0 0 0 / 18%));
}
.legacy-body {
fill: #000;
}
.legacy-eye {
fill: #fff;
}
.comparison-card footer {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 16px 24px 19px;
border-top: 1px solid rgb(255 255 255 / 12%);
color: #777d89;
font-size: 9px;
}
.legacy-card footer {
border-top-color: rgb(0 0 0 / 12%);
color: #565962;
}
.comparison-versus {
position: absolute;
z-index: 5;
top: 50%;
left: 50%;
display: grid;
width: 54px;
height: 54px;
place-items: center;
border: 7px solid #090a0c;
border-radius: 50%;
color: #0b0c0f;
background: #f1efea;
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 11px;
font-weight: 900;
transform: translate(-50%, -50%) rotate(-8deg);
}
.comparison-expression-panel {
padding: 28px;
border: 1px solid rgb(255 255 255 / 14%);
border-radius: 24px;
background: rgb(17 19 24 / 88%);
}
.comparison-expression-actions {
display: flex;
justify-content: flex-start;
margin-bottom: 14px;
}
.comparison-expression-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 24px;
margin-bottom: 22px;
}
.comparison-expression-heading h2 {
margin: 6px 0 0;
font-size: clamp(28px, 4vw, 48px);
font-weight: 400;
letter-spacing: -0.045em;
}
.comparison-expression-heading p {
max-width: 360px;
margin: 0;
color: #858a95;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 12px;
text-align: right;
}
.comparison-expression-grid {
display: grid;
grid-template-columns: repeat(13, minmax(0, 1fr));
gap: 8px;
}
.comparison-expression-button {
position: relative;
display: grid;
min-width: 0;
aspect-ratio: 0.78;
place-items: center;
overflow: hidden;
padding: 7px 6px 22px;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 12px;
color: #8d929d;
background: #181b21;
cursor: pointer;
transition:
border-color 160ms ease,
background 160ms ease,
transform 160ms ease;
}
.comparison-expression-button:hover {
z-index: 1;
border-color: rgb(255 255 255 / 38%);
transform: translateY(-3px);
}
.comparison-expression-button[aria-pressed='true'] {
border-color: #7592f1;
color: white;
background: #253156;
box-shadow: 0 0 0 2px rgb(117 146 241 / 20%);
}
.comparison-expression-button svg {
width: 100%;
aspect-ratio: 1;
}
.comparison-expression-button path {
fill: #f1f0ea;
}
.comparison-expression-button span {
position: absolute;
right: 7px;
bottom: 6px;
font-size: 8px;
}
.comparison-error {
display: grid;
min-height: 100vh;
place-content: center;
gap: 14px;
text-align: center;
}
.comparison-error a {
color: #8ea8ff;
}
@media (max-width: 980px) {
.comparison-header {
grid-template-columns: 1fr auto;
}
.comparison-title-lockup {
grid-column: 1 / -1;
grid-row: 2;
}
.comparison-expression-grid {
grid-template-columns: repeat(9, minmax(0, 1fr));
}
}
@media (max-width: 700px) {
.comparison-page {
padding-inline: 12px;
}
.comparison-stage {
grid-template-columns: 1fr;
}
.comparison-versus {
top: 50%;
}
.comparison-avatar-frame {
min-height: 390px;
}
.comparison-card footer {
gap: 8px;
padding-inline: 16px;
font-size: 7px;
}
.comparison-expression-panel {
padding: 20px 14px;
}
.comparison-expression-heading {
display: block;
}
.comparison-expression-heading p {
margin-top: 10px;
text-align: left;
}
.comparison-expression-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}

View File

@ -0,0 +1,23 @@
import legacyGrokSource from '../../../legacy/index.html?raw'
export type LegacyGrokPoint = readonly [number, number]
export type LegacyGrokExpression = readonly [LegacyGrokPoint[], LegacyGrokPoint[]]
export const LEGACY_GROK_BODY_PATH =
'M228.541 114.228C228.541 130.133 225.184 145.994 218.738 160.534C212.674 174.217 203.904 186.669 193.065 196.988C155.933 232.34 99.497 238.596 55.5255 212.24C45.097 205.99 35.6851 198.072 27.7451 188.866C19.1926 178.953 12.3686 167.569 7.65781 155.351C2.60712 142.264 0 128.257 0 114.228C0 98.3219 3.35751 82.4611 9.80315 67.9215C15.8672 54.2382 24.6377 41.7862 35.4767 31.4668C72.6081 -3.88483 129.044 -10.1413 173.016 16.2153C183.444 22.4653 192.856 30.3829 200.796 39.5896C209.349 49.5018 216.173 60.8859 220.883 73.1037C225.934 86.1906 228.541 100.198 228.541 114.228Z'
const startMarker = 'const EXPRESSIONS = '
const endMarker = '\n const GROUPS = '
const start = legacyGrokSource.indexOf(startMarker)
const end = legacyGrokSource.indexOf(endMarker, start)
if (start < 0 || end < 0) throw new Error('Legacy Grok expressions are missing')
const expressionLiteral = legacyGrokSource
.slice(start + startMarker.length, end)
.replace(/,\s*]/g, ']')
export const legacyGrokExpressions = JSON.parse(expressionLiteral) as LegacyGrokExpression[]
export const legacyGrokPath = (ring: readonly LegacyGrokPoint[]) =>
`M${ring.map(point => `${point[0].toFixed(2)} ${point[1].toFixed(2)}`).join('L')}Z`

View File

@ -20,5 +20,12 @@ export default defineConfig({
build: { build: {
outDir: path.join(root, 'dist'), outDir: path.join(root, 'dist'),
emptyOutDir: true, emptyOutDir: true,
rolldownOptions: {
input: {
main: path.join(root, 'index.html'),
grokCompare: path.join(root, 'grok-compare/index.html'),
captureLab: path.join(root, 'capture-lab/index.html'),
},
},
}, },
}) })