diff --git a/capture-lab/index.html b/capture-lab/index.html new file mode 100644 index 0000000..329b22b --- /dev/null +++ b/capture-lab/index.html @@ -0,0 +1,17 @@ + + + + + + + + Capture Lab — Bible Strong Avatar Lab + + +
+ + + diff --git a/grok-compare/index.html b/grok-compare/index.html new file mode 100644 index 0000000..bbd5f58 --- /dev/null +++ b/grok-compare/index.html @@ -0,0 +1,18 @@ + + + + + + + + + Grok Bot Remastered vs Official — Bible Strong Avatar Lab + + +
+ + + diff --git a/package.json b/package.json index 66df015..8862a27 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ }, "dependencies": { "@base-ui/react": "^1.7.0", + "@mediapipe/tasks-vision": "^1.0.1", "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", "class-variance-authority": "^0.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c26efc..bc8407d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@base-ui/react': specifier: ^1.7.0 version: 1.7.0(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@mediapipe/tasks-vision': + specifier: ^1.0.1 + version: 1.0.1 '@vercel/analytics': specifier: ^2.0.1 version: 2.0.1(react@19.2.3) @@ -219,6 +222,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mediapipe/tasks-vision@1.0.1': + resolution: {integrity: sha512-rvRE2FmAZ6ZxKSw7wq+e+jQDpN3t1B/tD2mJz9SmAzb1msoDkd4dMoE4wAh8Z30Um0PQwLiHr9QtomhmXk3aUQ==} + '@oxc-project/types@0.144.0': resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} @@ -1222,6 +1228,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mediapipe/tasks-vision@1.0.1': {} + '@oxc-project/types@0.144.0': {} '@rolldown/binding-android-arm64@1.2.4': diff --git a/src/features/capture/CaptureLabApp.tsx b/src/features/capture/CaptureLabApp.tsx new file mode 100644 index 0000000..63af966 --- /dev/null +++ b/src/features/capture/CaptureLabApp.tsx @@ -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 ( +
+
+ {label} + {signed ? value.toFixed(2) : `${Math.round(value * 100)}%`} +
+
+ {signed && } + +
+
+ ) +} + +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('idle') + const [errorMessage, setErrorMessage] = useState('') + const [telemetry, setTelemetry] = useState(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(null) + const [processedTake, setProcessedTake] = useState(null) + const [takeSettings, setTakeSettings] = useState(defaultMotionTakeSettings) + const [playhead, setPlayhead] = useState(0) + const [playing, setPlaying] = useState(false) + const videoRef = useRef(null) + const streamRef = useRef(null) + const workerRef = useRef(null) + const frameRequestRef = useRef(null) + const workerReadyRef = useRef(false) + const cameraReadyRef = useRef(false) + const processingRef = useRef(false) + const lastVideoTimeRef = useRef(-1) + const neutralRef = useRef(null) + const latestFrameRef = useRef(null) + const lastTelemetryAtRef = useRef(0) + const lastCaptureAtRef = useRef(0) + const smootherRef = useRef(createFacialCaptureSmoother(defaultSmoothing / 100)) + const recordingRef = useRef(false) + const recordingStartedAtRef = useRef(null) + const recordingSamplesRef = useRef([]) + const lastRecordingUiAtRef = useRef(0) + const playbackFrameRef = useRef(null) + const processedTakeRef = useRef(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) => { + 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 ( +
+
+ + + Avatar Lab + +
+ Experimental instrument · 01 +

Performance Capture

+
+ + Local processing + +
+ +
+
+ + + +
+
+ ) +} + +createRoot(document.getElementById('root')!).render( + + + + +) diff --git a/src/features/capture/MotionTakeEditor.tsx b/src/features/capture/MotionTakeEditor.tsx new file mode 100644 index 0000000..be8df88 --- /dev/null +++ b/src/features/capture/MotionTakeEditor.tsx @@ -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 ( +
+
+ + {format(value)} +
+ onChange(Number(event.currentTarget.value))} + /> +
+ ) +} + +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) => { + const bounds = event.currentTarget.getBoundingClientRect() + onSeek(((event.clientX - bounds.left) / bounds.width) * processedTake.duration) + } + const playheadX = processedTake.duration ? (playhead / processedTake.duration) * 100 : 0 + + return ( +
+
+
+ Recorded take · 01 +

Refine the gesture

+
+
+ {(processedTake.duration / 1000).toFixed(2)}s + {take.samples.length} samples +
+
+ +
+ Raw capture + Refined curve +
+ +
{ + event.currentTarget.setPointerCapture(event.pointerId) + seekFromPointer(event) + }} + onPointerMove={event => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) seekFromPointer(event) + }} + > + + {channels.map(({ channel, label, range, color }) => ( +
+ {label} + + + + + +
+ ))} +
+ +
+ `${Math.round(value * 100)}%`} + onChange={smoothing => onSettingsChange({ ...settings, smoothing })} + /> + `${Math.round(value * 100)}%`} + onChange={detailRetention => onSettingsChange({ ...settings, detailRetention })} + /> + `${value.toFixed(2)}×`} + onChange={amplitude => onSettingsChange({ ...settings, amplitude })} + /> + `${value.toFixed(2)}×`} + onChange={speed => onSettingsChange({ ...settings, speed })} + /> +
+ +
+ + + + +
+
+ ) +} diff --git a/src/features/capture/__tests__/facial-capture-test.ts b/src/features/capture/__tests__/facial-capture-test.ts new file mode 100644 index 0000000..7f58d9a --- /dev/null +++ b/src/features/capture/__tests__/facial-capture-test.ts @@ -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) + }) +}) diff --git a/src/features/capture/__tests__/motion-take-test.ts b/src/features/capture/__tests__/motion-take-test.ts new file mode 100644 index 0000000..e310284 --- /dev/null +++ b/src/features/capture/__tests__/motion-take-test.ts @@ -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) + }) +}) diff --git a/src/features/capture/captureLab.css b/src/features/capture/captureLab.css new file mode 100644 index 0000000..a7ae209 --- /dev/null +++ b/src/features/capture/captureLab.css @@ -0,0 +1,1133 @@ +@import 'tailwindcss'; + +:root { + color: #17191d; + background: #0d1014; + font-family: Georgia, 'Times New Roman', serif; + font-synthesis: none; +} + +* { + box-sizing: border-box; + user-select: none; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; +} + +button { + font: inherit; +} + +.capture-lab { + --ink: #101216; + --paper: #f2f0e9; + --blue: #5b7fe5; + min-height: 100svh; + overflow: hidden; + background: + linear-gradient(rgb(255 255 255 / 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgb(255 255 255 / 0.035) 1px, transparent 1px), #0d1014; + background-size: 36px 36px; +} + +.capture-header { + position: relative; + z-index: 5; + display: grid; + min-height: 92px; + grid-template-columns: 1fr auto 1fr; + align-items: center; + padding: 18px 32px; + border-bottom: 1px solid rgb(255 255 255 / 0.14); + color: #f6f5ef; +} + +.capture-back { + display: inline-flex; + width: fit-content; + align-items: center; + gap: 9px; + color: #a6acb7; + font: + 700 11px/1 ui-monospace, + 'SFMono-Regular', + Consolas, + monospace; + letter-spacing: 0.1em; + text-decoration: none; + text-transform: uppercase; + transition: color 160ms ease; +} + +.capture-back:hover, +.capture-back:focus-visible { + color: white; +} + +.capture-back svg, +.capture-privacy-badge svg { + width: 14px; +} + +.capture-title-lockup { + text-align: center; +} + +.capture-title-lockup span, +.capture-console-heading span, +.capture-avatar-label span, +.capture-future-signal span { + color: #7798f4; + font: + 700 10px/1 ui-monospace, + 'SFMono-Regular', + Consolas, + monospace; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.capture-title-lockup h1 { + margin: 4px 0 0; + font-size: clamp(22px, 2.2vw, 34px); + font-weight: 500; + letter-spacing: -0.035em; +} + +.capture-privacy-badge { + justify-self: end; + border-color: rgb(255 255 255 / 0.18); + color: #b9c6e9; + font: + 700 10px/1 ui-monospace, + 'SFMono-Regular', + Consolas, + monospace; + letter-spacing: 0.04em; +} + +.capture-workbench { + display: grid; + min-height: calc(100svh - 92px); + grid-template-columns: minmax(0, 1.55fr) minmax(360px, 0.75fr); +} + +.capture-stage { + position: relative; + display: grid; + min-height: 620px; + place-items: center; + overflow: hidden; + border-right: 1px solid rgb(255 255 255 / 0.14); + background: + radial-gradient(circle at 50% 48%, rgb(91 127 229 / 0.16), transparent 39%), + linear-gradient(135deg, #11161d, #0a0d11 72%); +} + +.capture-stage::after { + position: absolute; + width: min(64vw, 720px); + aspect-ratio: 1; + border: 1px solid rgb(255 255 255 / 0.055); + border-radius: 50%; + box-shadow: + 0 0 0 80px rgb(255 255 255 / 0.012), + 0 0 0 160px rgb(255 255 255 / 0.008); + content: ''; + pointer-events: none; +} + +.capture-stage-grid { + position: absolute; + inset: 0; + background: + linear-gradient(transparent 49.8%, rgb(119 152 244 / 0.12) 50%, transparent 50.2%), + linear-gradient(90deg, transparent 49.8%, rgb(119 152 244 / 0.12) 50%, transparent 50.2%); + pointer-events: none; +} + +.capture-avatar { + z-index: 2; + width: min(66vh, 68%); + max-width: 680px; + overflow: visible; + filter: drop-shadow(0 34px 38px rgb(0 0 0 / 0.4)); +} + +.capture-avatar-body { + fill: var(--capture-body); +} + +.capture-avatar-eye { + fill: var(--capture-eyes); +} + +.capture-avatar-label { + position: absolute; + z-index: 3; + top: 28px; + left: 30px; + display: grid; + gap: 5px; + color: white; +} + +.capture-avatar-label strong { + font-size: 17px; + font-weight: 500; +} + +.capture-lock-status { + position: absolute; + z-index: 3; + bottom: 28px; + left: 30px; + display: inline-flex; + align-items: center; + gap: 8px; + color: #9ba2ad; + font: + 700 10px/1 ui-monospace, + 'SFMono-Regular', + Consolas, + monospace; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.capture-lock-status > span { + width: 8px; + height: 8px; + border-radius: 50%; + background: #626873; + box-shadow: 0 0 0 4px rgb(98 104 115 / 0.12); +} + +.capture-lock-status.is-tracking > span { + background: #58d891; + box-shadow: + 0 0 0 4px rgb(88 216 145 / 0.13), + 0 0 18px rgb(88 216 145 / 0.5); + animation: capture-pulse 1.6s ease-in-out infinite; +} + +.capture-lock-status.is-take > span { + background: #7798f4; + box-shadow: + 0 0 0 4px rgb(119 152 244 / 0.13), + 0 0 18px rgb(119 152 244 / 0.45); +} + +.capture-lock-status.is-loading > span { + background: #e8bd48; +} + +.capture-lock-status.is-lost > span, +.capture-lock-status.is-error > span { + background: #e96666; +} + +@keyframes capture-pulse { + 50% { + transform: scale(0.68); + opacity: 0.65; + } +} + +.capture-camera-card { + position: absolute; + z-index: 4; + top: 28px; + right: 28px; + width: clamp(150px, 18vw, 230px); + aspect-ratio: 4 / 3; + overflow: hidden; + border: 1px solid rgb(255 255 255 / 0.2); + border-radius: 4px; + background: #171b21; + box-shadow: 0 18px 45px rgb(0 0 0 / 0.42); +} + +.capture-camera-card.is-hidden { + display: none; +} + +.capture-camera-card video { + width: 100%; + height: 100%; + object-fit: cover; + transform: scaleX(-1); +} + +.capture-camera-placeholder { + position: absolute; + inset: 0; + display: grid; + place-content: center; + gap: 8px; + color: #646b76; + font: + 700 9px/1 ui-monospace, + 'SFMono-Regular', + Consolas, + monospace; + letter-spacing: 0.08em; + text-align: center; + text-transform: uppercase; +} + +.capture-camera-placeholder svg { + width: 23px; + margin: auto; +} + +.capture-camera-caption { + position: absolute; + right: 7px; + bottom: 7px; + padding: 4px 5px; + border-radius: 2px; + background: rgb(8 10 13 / 0.72); + color: #bdc3ce; + font: + 700 8px/1 ui-monospace, + 'SFMono-Regular', + Consolas, + monospace; + letter-spacing: 0.1em; +} + +.capture-camera-reticle i { + position: absolute; + width: 17px; + height: 17px; + border-color: #7fa0fb; +} + +.capture-camera-reticle i:nth-child(1) { + top: 10px; + left: 10px; + border-top: 1px solid; + border-left: 1px solid; +} + +.capture-camera-reticle i:nth-child(2) { + top: 10px; + right: 10px; + border-top: 1px solid; + border-right: 1px solid; +} + +.capture-camera-reticle i:nth-child(3) { + bottom: 10px; + left: 10px; + border-bottom: 1px solid; + border-left: 1px solid; +} + +.capture-camera-reticle i:nth-child(4) { + right: 10px; + bottom: 10px; + border-right: 1px solid; + border-bottom: 1px solid; +} + +.capture-console { + display: flex; + min-width: 0; + flex-direction: column; + overflow: auto; + padding: clamp(26px, 3.2vw, 52px); + background: var(--paper); +} + +.capture-console-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + padding-bottom: 26px; + border-bottom: 1px solid #cbc9c1; +} + +.capture-console-heading h2 { + margin: 7px 0 0; + font-size: clamp(23px, 2.3vw, 34px); + font-weight: 500; + letter-spacing: -0.04em; +} + +.capture-console-heading svg { + width: 21px; + color: #98978f; +} + +.capture-console-heading svg.is-active { + color: #2ba96b; +} + +.capture-orientation { + display: grid; + grid-template-columns: repeat(3, 1fr); + border-bottom: 1px solid #cbc9c1; +} + +.capture-position { + display: grid; + grid-template-columns: repeat(3, 1fr); + border-bottom: 1px solid #cbc9c1; +} + +.capture-position > div { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + padding: 12px 10px; + border-right: 1px solid #cbc9c1; +} + +.capture-position > div:first-child { + padding-left: 0; +} + +.capture-position > div:last-child { + padding-right: 0; + border-right: 0; +} + +.capture-position span { + color: #777970; + font: + 700 8px/1 ui-monospace, + 'SFMono-Regular', + Consolas, + monospace; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.capture-position strong { + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +.capture-orientation > div { + display: grid; + gap: 7px; + padding: 23px 13px 22px; + border-right: 1px solid #cbc9c1; +} + +.capture-orientation > div:first-child { + padding-left: 0; +} + +.capture-orientation > div:last-child { + border-right: 0; +} + +.capture-orientation span, +.capture-signal-label, +.capture-privacy-note, +.capture-camera-placeholder { + color: #777970; + font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace; +} + +.capture-orientation span { + font-size: 9px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.capture-orientation strong { + font-size: clamp(21px, 2vw, 29px); + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +.capture-signal-group { + display: grid; + gap: 20px; + padding: 28px 0; + border-bottom: 1px solid #cbc9c1; +} + +.capture-smoothing-control { + display: grid; + gap: 11px; + padding: 22px 0; + border-bottom: 1px solid #cbc9c1; +} + +.capture-smoothing-heading, +.capture-smoothing-scale { + display: flex; + align-items: center; + justify-content: space-between; + color: #777970; + font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace; + text-transform: uppercase; +} + +.capture-smoothing-heading { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; +} + +.capture-smoothing-heading output { + color: #202329; + font-variant-numeric: tabular-nums; +} + +.capture-smoothing-control input { + width: 100%; + margin: 0; + accent-color: var(--blue); + cursor: ew-resize; +} + +.capture-smoothing-scale { + color: #97978f; + font-size: 8px; + font-weight: 700; + letter-spacing: 0.08em; +} + +.capture-signal { + display: grid; + gap: 9px; +} + +.capture-signal-label { + display: flex; + justify-content: space-between; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.capture-signal-label output { + color: #202329; + font-variant-numeric: tabular-nums; +} + +.capture-signal-track { + position: relative; + height: 5px; + overflow: hidden; + border-radius: 999px; + background: #d5d3cb; +} + +.capture-signal-fill { + position: absolute; + inset: 0; + border-radius: inherit; + background: var(--blue); + transform-origin: left; +} + +.capture-signal-zero { + position: absolute; + z-index: 2; + top: -2px; + bottom: -2px; + left: 50%; + width: 1px; + background: #34383f; +} + +.capture-future-signal { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 22px 0; + border-bottom: 1px solid #cbc9c1; +} + +.capture-future-signal > div { + display: grid; + gap: 5px; +} + +.capture-future-signal span { + color: #97978f; +} + +.capture-future-signal strong { + font-size: 15px; + font-weight: 500; +} + +.capture-future-signal output { + color: #a2a198; + font: + 700 13px/1 ui-monospace, + 'SFMono-Regular', + Consolas, + monospace; +} + +.capture-expression-observer { + display: grid; + gap: 14px; + padding: 22px 0; + border-bottom: 1px solid #cbc9c1; +} + +.capture-expression-summary { + display: flex; + align-items: end; + justify-content: space-between; + gap: 18px; +} + +.capture-expression-summary > div { + display: grid; + gap: 6px; +} + +.capture-expression-summary span { + color: #7798f4; + font: + 700 9px/1 ui-monospace, + 'SFMono-Regular', + Consolas, + monospace; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.capture-expression-summary strong { + font-size: 20px; + font-weight: 500; +} + +.capture-expression-summary > output { + color: #202329; + font: + 700 16px/1 ui-monospace, + 'SFMono-Regular', + Consolas, + monospace; + font-variant-numeric: tabular-nums; +} + +.capture-expression-scores { + display: grid; + gap: 8px; +} + +.capture-expression-scores > div { + display: grid; + grid-template-columns: 76px 1fr 22px; + align-items: center; + gap: 9px; + color: #74766e; + font: + 700 8px/1 ui-monospace, + 'SFMono-Regular', + Consolas, + monospace; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.capture-expression-scores i { + position: relative; + height: 4px; + overflow: hidden; + border-radius: 999px; + background: #d5d3cb; +} + +.capture-expression-scores b { + position: absolute; + inset: 0; + border-radius: inherit; + background: var(--blue); + transform-origin: left; +} + +.capture-expression-scores output { + color: #202329; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.capture-expression-observer p { + margin: 0; + color: #92938b; + font: + 8px/1.45 ui-monospace, + 'SFMono-Regular', + Consolas, + monospace; +} + +.capture-calibration-card { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 14px; + margin-top: 26px; + padding: 15px; + border: 1px solid #cbc9c1; + border-radius: 4px; + background: #e9e7df; +} + +.capture-calibration-card > svg { + width: 20px; + color: #5277de; +} + +.capture-calibration-card strong { + font-size: 13px; +} + +.capture-calibration-card p { + margin: 3px 0 0; + color: #73756d; + font: + 10px/1.4 ui-monospace, + 'SFMono-Regular', + Consolas, + monospace; +} + +.capture-calibration-card button { + border-color: #c1bfb7; + background: #f5f3ec; +} + +.capture-error-message { + margin: 16px 0 0; + padding: 10px 12px; + border-left: 3px solid #d45252; + background: #f2dede; + color: #812d2d; + font: + 11px/1.45 ui-monospace, + 'SFMono-Regular', + Consolas, + monospace; +} + +.capture-console-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-top: auto; + padding-top: 26px; +} + +.capture-console-actions button { + min-width: 120px; + flex: 1; + height: 42px; + border-radius: 3px; + font: + 700 11px/1 ui-monospace, + 'SFMono-Regular', + Consolas, + monospace; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.capture-start-button { + background: #15191f; + color: white; +} + +.capture-record-button { + background: #15191f; + color: white; +} + +.capture-record-button svg { + fill: #eb625b; + color: #eb625b; +} + +.capture-record-button.is-recording { + background: #a93632; +} + +.capture-record-button.is-recording svg { + fill: currentColor; + color: currentColor; +} + +.capture-stop-button { + border-color: #bbb9b0; + background: transparent; +} + +.capture-console-actions > span { + flex-basis: 100%; + text-align: right; + color: #6e7169; + font: + 700 10px/1 ui-monospace, + 'SFMono-Regular', + Consolas, + monospace; + letter-spacing: 0.08em; +} + +.capture-privacy-note { + margin: 15px 0 0; + font-size: 9px; + line-height: 1.5; +} + +.take-editor { + display: flex; + min-height: 100%; + flex-direction: column; +} + +.take-editor-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + padding-bottom: 20px; + border-bottom: 1px solid #cbc9c1; +} + +.take-editor-heading > div:first-child > span, +.take-editor-duration span, +.take-editor-legend, +.take-channel > span, +.take-editor-slider, +.take-editor-actions > span { + font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace; + text-transform: uppercase; +} + +.take-editor-heading > div:first-child > span { + color: #7798f4; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.12em; +} + +.take-editor-heading h2 { + margin: 7px 0 0; + font-size: clamp(25px, 2.2vw, 34px); + font-weight: 500; + letter-spacing: -0.04em; +} + +.take-editor-duration { + display: grid; + gap: 4px; + text-align: right; +} + +.take-editor-duration strong { + font-size: 19px; + font-variant-numeric: tabular-nums; +} + +.take-editor-duration span { + color: #85877f; + font-size: 8px; + letter-spacing: 0.08em; +} + +.take-editor-legend { + display: flex; + gap: 18px; + padding: 14px 0 12px; + color: #7e8078; + font-size: 8px; + font-weight: 700; + letter-spacing: 0.08em; +} + +.take-editor-legend span::before { + display: inline-block; + width: 16px; + margin-right: 6px; + border-top: 2px solid; + content: ''; + vertical-align: middle; +} + +.take-editor-legend .is-raw::before { + border-top-color: #8c8d88; + border-top-style: dashed; +} + +.take-editor-legend .is-refined::before { + border-top-color: #567ef0; +} + +.take-timeline { + position: relative; + overflow: hidden; + border: 1px solid #252a31; + border-radius: 4px; + background: linear-gradient(90deg, rgb(255 255 255 / 0.035) 1px, transparent 1px), #11151a; + background-size: 10% 100%; + cursor: crosshair; + touch-action: none; +} + +.take-playhead { + position: absolute; + z-index: 3; + top: 0; + bottom: 0; + width: 1px; + background: #f2f0e9; + box-shadow: 0 0 0 1px rgb(255 255 255 / 0.1); + pointer-events: none; +} + +.take-playhead::before { + position: absolute; + top: 0; + left: -4px; + width: 9px; + height: 5px; + background: #f2f0e9; + clip-path: polygon(0 0, 100% 0, 50% 100%); + content: ''; +} + +.take-channel { + display: grid; + min-height: 42px; + grid-template-columns: 58px 1fr; + align-items: stretch; + border-bottom: 1px solid rgb(255 255 255 / 0.08); +} + +.take-channel:last-child { + border-bottom: 0; +} + +.take-channel > span { + display: grid; + place-items: center start; + padding-left: 10px; + border-right: 1px solid rgb(255 255 255 / 0.08); + color: #8b929d; + font-size: 7px; + font-weight: 700; + letter-spacing: 0.08em; +} + +.take-channel svg { + width: 100%; + height: 100%; + overflow: visible; +} + +.take-channel line { + stroke: rgb(255 255 255 / 0.09); + stroke-width: 0.35; +} + +.take-channel path { + fill: none; + vector-effect: non-scaling-stroke; +} + +.take-raw-curve { + stroke: #797f88; + stroke-width: 1; + stroke-dasharray: 2 3; + opacity: 0.55; +} + +.take-refined-curve { + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; +} + +.take-editor-controls { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px 22px; + padding: 22px 0; + border-bottom: 1px solid #cbc9c1; +} + +.take-editor-slider { + display: grid; + gap: 7px; +} + +.take-editor-slider > div { + display: flex; + justify-content: space-between; + color: #74766f; + font-size: 8px; + font-weight: 700; + letter-spacing: 0.06em; +} + +.take-editor-slider output { + color: #1d2025; + font-variant-numeric: tabular-nums; +} + +.take-editor-slider input { + width: 100%; + margin: 0; + accent-color: var(--blue); + cursor: ew-resize; +} + +.take-editor-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-top: auto; + padding-top: 24px; +} + +.take-editor-actions button { + height: 42px; + border-radius: 3px; + font: + 700 10px/1 ui-monospace, + 'SFMono-Regular', + Consolas, + monospace; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.take-play-button { + background: #15191f; + color: white; +} + +.take-editor-actions > span { + display: inline-flex; + flex-basis: 100%; + align-items: center; + gap: 6px; + color: #878981; + font-size: 8px; + font-weight: 700; + letter-spacing: 0.06em; +} + +.take-editor-actions > span svg { + width: 13px; +} + +@media (max-width: 900px) { + .capture-lab { + overflow: visible; + } + + .capture-header { + min-height: 76px; + grid-template-columns: 1fr auto; + padding: 16px 18px; + } + + .capture-title-lockup { + display: none; + } + + .capture-workbench { + min-height: 0; + grid-template-columns: 1fr; + } + + .capture-stage { + min-height: min(65svh, 620px); + border-right: 0; + border-bottom: 1px solid rgb(255 255 255 / 0.14); + } + + .capture-avatar { + width: min(60vh, 75vw); + } + + .capture-camera-card { + width: 150px; + } + + .capture-console { + min-height: 650px; + } +} + +@media (max-width: 560px) { + .capture-privacy-badge { + padding-inline: 7px; + font-size: 8px; + } + + .capture-stage { + min-height: 58svh; + } + + .capture-camera-card { + top: 16px; + right: 16px; + width: 118px; + } + + .capture-avatar-label { + top: 18px; + left: 18px; + } + + .capture-avatar-label strong { + max-width: 130px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .capture-lock-status { + bottom: 18px; + left: 18px; + } + + .capture-console { + min-height: 680px; + padding: 28px 20px; + } + + .capture-calibration-card { + grid-template-columns: auto 1fr; + } + + .capture-calibration-card button { + grid-column: 1 / -1; + width: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + .capture-lock-status.is-tracking > span { + animation: none; + } +} diff --git a/src/features/capture/faceLandmarker.worker.ts b/src/features/capture/faceLandmarker.worker.ts new file mode 100644 index 0000000..f19cc34 --- /dev/null +++ b/src/features/capture/faceLandmarker.worker.ts @@ -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) => { + 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.', + }) + } +}) diff --git a/src/features/capture/facialCapture.ts b/src/features/capture/facialCapture.ts new file mode 100644 index 0000000..3579650 --- /dev/null +++ b/src/features/capture/facialCapture.ts @@ -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>, 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>, + 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 }, + } +} diff --git a/src/features/capture/motionTake.ts b/src/features/capture/motionTake.ts new file mode 100644 index 0000000..d54249f --- /dev/null +++ b/src/features/capture/motionTake.ts @@ -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 + 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 +} diff --git a/src/features/comparison/GrokComparisonApp.tsx b/src/features/comparison/GrokComparisonApp.tsx new file mode 100644 index 0000000..6850ae6 --- /dev/null +++ b/src/features/comparison/GrokComparisonApp.tsx @@ -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 ( + + Official Grok Bot, expression {index} + + + + + + + + + {expression.map((ring, eyeIndex) => { + const center = legacyRingCentroid(ring) + return ( + + ) + })} + + + + ) +} + +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 ( +
+ Grok Bot could not be found. + Return to Avatar Lab +
+ ) + } + + 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(() => + poseWithAvatarEyes(behavior.expressions[0], grokAvatar.eyes) + ) + const [displayedLegacyExpression, setDisplayedLegacyExpression] = useState( + () => legacyGrokExpressions[0] + ) + const displayedPoseRef = useRef(displayedPose) + const displayedLegacyRef = useRef(displayedLegacyExpression) + const comparisonFrame = useRef(null) + const blinkAnimation = useRef(null) + const rotationAnimation = useRef(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): 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) => { + 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) => { + 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 ( +
+
+ + +
+
+
+
+
+ +
+
+
+
+

Procedural 3D engine

+

+ Each expression interpolates a three-dimensional pose. The engine rebuilds and + projects the body and eyes as SVG on every frame. +

+
+
+
+ + Grok Bot remastered, expression {activeIndex} + + + + + + + {geometry.backPaths.map((path, index) => ( + + ))} + + + {geometry.leftVisible && } + {geometry.rightVisible && } + + {geometry.frontPaths.map((path, index) => ( + + ))} + + + +
+
+ + + +
+
+
+

SVG morphing

+

+ Each transition morphs the 48 eye points between 25 fixed expressions. The body + remains a single, unchanged SVG path. +

+
+
+
+ +
+
+
+ +
+
+ +
+
+ {legacyGrokExpressions.slice(0, expressionCount).map((legacyExpression, index) => ( + + ))} +
+
+
+ ) +} + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/src/features/comparison/__tests__/legacy-grok-test.ts b/src/features/comparison/__tests__/legacy-grok-test.ts new file mode 100644 index 0000000..02867e2 --- /dev/null +++ b/src/features/comparison/__tests__/legacy-grok-test.ts @@ -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) + }) +}) diff --git a/src/features/comparison/comparisonTransition.ts b/src/features/comparison/comparisonTransition.ts new file mode 100644 index 0000000..d3c0f3f --- /dev/null +++ b/src/features/comparison/comparisonTransition.ts @@ -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 diff --git a/src/features/comparison/grokComparison.css b/src/features/comparison/grokComparison.css new file mode 100644 index 0000000..4934624 --- /dev/null +++ b/src/features/comparison/grokComparison.css @@ -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)); + } +} diff --git a/src/features/comparison/legacyGrok.ts b/src/features/comparison/legacyGrok.ts new file mode 100644 index 0000000..5d7e8d4 --- /dev/null +++ b/src/features/comparison/legacyGrok.ts @@ -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` diff --git a/vite.config.ts b/vite.config.ts index b8015e9..155465b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -20,5 +20,12 @@ export default defineConfig({ build: { outDir: path.join(root, 'dist'), emptyOutDir: true, + rolldownOptions: { + input: { + main: path.join(root, 'index.html'), + grokCompare: path.join(root, 'grok-compare/index.html'), + captureLab: path.join(root, 'capture-lab/index.html'), + }, + }, }, })