Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18abc5c82f |
17
capture-lab/index.html
Normal file
17
capture-lab/index.html
Normal 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
18
grok-compare/index.html
Normal 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>
|
||||
@ -33,6 +33,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@mediapipe/tasks-vision": "^1.0.1",
|
||||
"@vercel/analytics": "^2.0.1",
|
||||
"@vercel/speed-insights": "^2.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -11,6 +11,9 @@ importers:
|
||||
'@base-ui/react':
|
||||
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)
|
||||
'@mediapipe/tasks-vision':
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1
|
||||
'@vercel/analytics':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1(react@19.2.3)
|
||||
@ -219,6 +222,9 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@mediapipe/tasks-vision@1.0.1':
|
||||
resolution: {integrity: sha512-rvRE2FmAZ6ZxKSw7wq+e+jQDpN3t1B/tD2mJz9SmAzb1msoDkd4dMoE4wAh8Z30Um0PQwLiHr9QtomhmXk3aUQ==}
|
||||
|
||||
'@oxc-project/types@0.144.0':
|
||||
resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==}
|
||||
|
||||
@ -1222,6 +1228,8 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@mediapipe/tasks-vision@1.0.1': {}
|
||||
|
||||
'@oxc-project/types@0.144.0': {}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.2.4':
|
||||
|
||||
811
src/features/capture/CaptureLabApp.tsx
Normal file
811
src/features/capture/CaptureLabApp.tsx
Normal 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>
|
||||
)
|
||||
221
src/features/capture/MotionTakeEditor.tsx
Normal file
221
src/features/capture/MotionTakeEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
src/features/capture/__tests__/facial-capture-test.ts
Normal file
141
src/features/capture/__tests__/facial-capture-test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
85
src/features/capture/__tests__/motion-take-test.ts
Normal file
85
src/features/capture/__tests__/motion-take-test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
1133
src/features/capture/captureLab.css
Normal file
1133
src/features/capture/captureLab.css
Normal file
File diff suppressed because it is too large
Load Diff
64
src/features/capture/faceLandmarker.worker.ts
Normal file
64
src/features/capture/faceLandmarker.worker.ts
Normal 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.',
|
||||
})
|
||||
}
|
||||
})
|
||||
326
src/features/capture/facialCapture.ts
Normal file
326
src/features/capture/facialCapture.ts
Normal 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 },
|
||||
}
|
||||
}
|
||||
191
src/features/capture/motionTake.ts
Normal file
191
src/features/capture/motionTake.ts
Normal 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
|
||||
}
|
||||
413
src/features/comparison/GrokComparisonApp.tsx
Normal file
413
src/features/comparison/GrokComparisonApp.tsx
Normal 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>
|
||||
)
|
||||
52
src/features/comparison/__tests__/legacy-grok-test.ts
Normal file
52
src/features/comparison/__tests__/legacy-grok-test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
45
src/features/comparison/comparisonTransition.ts
Normal file
45
src/features/comparison/comparisonTransition.ts
Normal 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
|
||||
461
src/features/comparison/grokComparison.css
Normal file
461
src/features/comparison/grokComparison.css
Normal 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));
|
||||
}
|
||||
}
|
||||
23
src/features/comparison/legacyGrok.ts
Normal file
23
src/features/comparison/legacyGrok.ts
Normal 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`
|
||||
@ -20,5 +20,12 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: path.join(root, 'dist'),
|
||||
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'),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user