testing stuff
This commit is contained in:
parent
e205c57687
commit
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": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.7.0",
|
"@base-ui/react": "^1.7.0",
|
||||||
|
"@mediapipe/tasks-vision": "^1.0.1",
|
||||||
"@vercel/analytics": "^2.0.1",
|
"@vercel/analytics": "^2.0.1",
|
||||||
"@vercel/speed-insights": "^2.0.0",
|
"@vercel/speed-insights": "^2.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -11,6 +11,9 @@ importers:
|
|||||||
'@base-ui/react':
|
'@base-ui/react':
|
||||||
specifier: ^1.7.0
|
specifier: ^1.7.0
|
||||||
version: 1.7.0(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 1.7.0(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@mediapipe/tasks-vision':
|
||||||
|
specifier: ^1.0.1
|
||||||
|
version: 1.0.1
|
||||||
'@vercel/analytics':
|
'@vercel/analytics':
|
||||||
specifier: ^2.0.1
|
specifier: ^2.0.1
|
||||||
version: 2.0.1(react@19.2.3)
|
version: 2.0.1(react@19.2.3)
|
||||||
@ -219,6 +222,9 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@mediapipe/tasks-vision@1.0.1':
|
||||||
|
resolution: {integrity: sha512-rvRE2FmAZ6ZxKSw7wq+e+jQDpN3t1B/tD2mJz9SmAzb1msoDkd4dMoE4wAh8Z30Um0PQwLiHr9QtomhmXk3aUQ==}
|
||||||
|
|
||||||
'@oxc-project/types@0.144.0':
|
'@oxc-project/types@0.144.0':
|
||||||
resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==}
|
resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==}
|
||||||
|
|
||||||
@ -1222,6 +1228,8 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@mediapipe/tasks-vision@1.0.1': {}
|
||||||
|
|
||||||
'@oxc-project/types@0.144.0': {}
|
'@oxc-project/types@0.144.0': {}
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.2.4':
|
'@rolldown/binding-android-arm64@1.2.4':
|
||||||
|
|||||||
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: {
|
build: {
|
||||||
outDir: path.join(root, 'dist'),
|
outDir: path.join(root, 'dist'),
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
rolldownOptions: {
|
||||||
|
input: {
|
||||||
|
main: path.join(root, 'index.html'),
|
||||||
|
grokCompare: path.join(root, 'grok-compare/index.html'),
|
||||||
|
captureLab: path.join(root, 'capture-lab/index.html'),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user