feat(vision): add dependency preflight checks and inline training capture

- Add preflight API endpoint to check Python dependencies before training
- Create DependencyCard wizard step showing installed/missing packages
- Create reusable CameraCapture component supporting local + phone cameras
- Add inline training data capture directly in the training wizard
- Update venv setup to install all requirements from requirements.txt
- Block training start until all dependencies verified

The wizard now shows a dedicated Dependencies step between Hardware and
Config, displaying which packages are installed and providing pip install
commands for any missing packages.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2026-01-06 12:16:28 -06:00
parent 0e1e7cf25d
commit 47a98f1bb7
12 changed files with 1787 additions and 51 deletions

View File

@ -92,6 +92,57 @@ interface SetupResult {
hasGpu: boolean
}
/**
* Required Python modules for training
*/
const REQUIRED_MODULES = [
{ name: 'tensorflow', importName: 'tensorflow', pipName: 'tensorflow' },
{ name: 'PIL (Pillow)', importName: 'PIL', pipName: 'Pillow' },
{ name: 'sklearn (scikit-learn)', importName: 'sklearn', pipName: 'scikit-learn' },
{ name: 'numpy', importName: 'numpy', pipName: 'numpy' },
]
export interface DependencyCheckResult {
allInstalled: boolean
missing: { name: string; pipName: string }[]
installed: { name: string; pipName: string }[]
error?: string
}
/**
* Check if all required Python dependencies are installed
*/
export async function checkDependencies(): Promise<DependencyCheckResult> {
if (!fs.existsSync(TRAINING_PYTHON)) {
return {
allInstalled: false,
missing: REQUIRED_MODULES.map((m) => ({ name: m.name, pipName: m.pipName })),
installed: [],
error: 'Python virtual environment not found',
}
}
const missing: { name: string; pipName: string }[] = []
const installed: { name: string; pipName: string }[] = []
for (const mod of REQUIRED_MODULES) {
try {
await execAsync(`"${TRAINING_PYTHON}" -c "import ${mod.importName}"`, {
timeout: 10000,
})
installed.push({ name: mod.name, pipName: mod.pipName })
} catch {
missing.push({ name: mod.name, pipName: mod.pipName })
}
}
return {
allInstalled: missing.length === 0,
missing,
installed,
}
}
/**
* Find the best Python to use for the venv.
* On Apple Silicon, prefer Homebrew ARM Python for Metal GPU support.
@ -145,6 +196,11 @@ async function isVenvReady(): Promise<boolean> {
}
}
/**
* Path to the requirements.txt file
*/
const REQUIREMENTS_FILE = path.join(TRAINING_SCRIPTS_DIR, 'requirements.txt')
/**
* Create the venv and install dependencies
*/
@ -178,6 +234,29 @@ async function createVenv(): Promise<SetupResult> {
})
}
// Install all other requirements from requirements.txt
if (fs.existsSync(REQUIREMENTS_FILE)) {
console.log('[vision-training] Installing additional requirements from requirements.txt...')
await execAsync(`"${TRAINING_PYTHON}" -m pip install -r "${REQUIREMENTS_FILE}"`, {
timeout: 600000,
})
} else {
// Fallback: install known required packages individually
console.log(
'[vision-training] requirements.txt not found, installing packages individually...'
)
const packages = ['Pillow', 'scikit-learn', 'numpy', 'tensorflowjs']
for (const pkg of packages) {
try {
await execAsync(`"${TRAINING_PYTHON}" -m pip install "${pkg}"`, {
timeout: 300000,
})
} catch (e) {
console.warn(`[vision-training] Failed to install ${pkg}: ${e}`)
}
}
}
// Verify installation
const { stdout } = await execAsync(
`"${TRAINING_PYTHON}" -c "import tensorflow as tf; print(len(tf.config.list_physical_devices('GPU')))"`,

View File

@ -0,0 +1,96 @@
import { NextResponse } from 'next/server'
import { checkDependencies, ensureVenvReady, isPlatformSupported, TRAINING_PYTHON } from '../config'
export interface PreflightResult {
ready: boolean
platform: {
supported: boolean
reason?: string
}
venv: {
exists: boolean
python: string
isAppleSilicon: boolean
hasGpu: boolean
error?: string
}
dependencies: {
allInstalled: boolean
installed: { name: string; pipName: string }[]
missing: { name: string; pipName: string }[]
error?: string
}
}
/**
* GET /api/vision-training/preflight
*
* Performs a complete preflight check for training readiness:
* 1. Platform support (TensorFlow availability)
* 2. Venv existence and setup
* 3. All required Python dependencies installed
*
* Returns a structured result indicating if training can proceed.
*/
export async function GET(): Promise<NextResponse<PreflightResult>> {
// Check platform support first
const platformCheck = isPlatformSupported()
if (!platformCheck.supported) {
return NextResponse.json({
ready: false,
platform: platformCheck,
venv: {
exists: false,
python: TRAINING_PYTHON,
isAppleSilicon: false,
hasGpu: false,
},
dependencies: {
allInstalled: false,
installed: [],
missing: [],
error: 'Platform not supported',
},
})
}
// Check/setup venv
const venvResult = await ensureVenvReady()
if (!venvResult.success) {
return NextResponse.json({
ready: false,
platform: { supported: true },
venv: {
exists: false,
python: venvResult.python,
isAppleSilicon: venvResult.isAppleSilicon,
hasGpu: venvResult.hasGpu,
error: venvResult.error,
},
dependencies: {
allInstalled: false,
installed: [],
missing: [],
error: 'Venv setup failed',
},
})
}
// Check all dependencies
const depResult = await checkDependencies()
const ready = depResult.allInstalled
return NextResponse.json({
ready,
platform: { supported: true },
venv: {
exists: true,
python: venvResult.python,
isAppleSilicon: venvResult.isAppleSilicon,
hasGpu: venvResult.hasGpu,
},
dependencies: depResult,
})
}

View File

@ -0,0 +1,281 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { css } from '../../../../../styled-system/css'
import { CameraCapture, type CameraSource } from '@/components/vision/CameraCapture'
interface TrainingDataCaptureProps {
/** Called when samples are saved successfully */
onSamplesCollected: () => void
/** Number of physical abacus columns (default 4) */
columnCount?: number
}
/**
* Inline training data capture component for the training wizard
*
* Uses the reusable CameraCapture component which supports both
* local device camera and phone camera via QR code.
*
* Captures abacus images and saves them as training data for the column classifier.
*/
export function TrainingDataCapture({
onSamplesCollected,
columnCount = 4,
}: TrainingDataCaptureProps) {
// Capture state
const [inputValue, setInputValue] = useState('')
const [isCapturing, setIsCapturing] = useState(false)
const [lastCaptureStatus, setLastCaptureStatus] = useState<{
success: boolean
message: string
} | null>(null)
const [captureCount, setCaptureCount] = useState(0)
const [isPhoneConnected, setIsPhoneConnected] = useState(false)
const [cameraSource, setCameraSource] = useState<CameraSource>('local')
const inputRef = useRef<HTMLInputElement>(null)
const captureElementRef = useRef<HTMLImageElement | HTMLVideoElement | null>(null)
// Handle capture from camera
const handleCapture = useCallback((element: HTMLImageElement | HTMLVideoElement) => {
captureElementRef.current = element
}, [])
// Capture training data
const captureTrainingData = useCallback(async () => {
const value = parseInt(inputValue, 10)
if (Number.isNaN(value) || value < 0) {
setLastCaptureStatus({ success: false, message: 'Enter a valid non-negative number' })
return
}
// Get the current capture element
const element = captureElementRef.current
if (!element) {
setLastCaptureStatus({ success: false, message: 'No camera frame available' })
return
}
// For video, check if it's playing
if (element instanceof HTMLVideoElement) {
if (element.readyState < 2) {
setLastCaptureStatus({ success: false, message: 'Camera not ready' })
return
}
}
// For image, check if it's loaded
if (element instanceof HTMLImageElement) {
if (!element.complete || element.naturalWidth === 0) {
setLastCaptureStatus({ success: false, message: 'Camera frame not ready' })
return
}
}
setIsCapturing(true)
setLastCaptureStatus(null)
try {
// Import frame processor dynamically
const { processImageFrame } = await import('@/lib/vision/frameProcessor')
const { imageDataToBase64Png } = await import('@/lib/vision/trainingData')
// For video elements, we need to draw to a temp image first
let imageElement: HTMLImageElement
if (element instanceof HTMLVideoElement) {
// Create a canvas to capture the video frame
const canvas = document.createElement('canvas')
canvas.width = element.videoWidth
canvas.height = element.videoHeight
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('Failed to create canvas context')
}
ctx.drawImage(element, 0, 0)
// Convert canvas to image
imageElement = new Image()
imageElement.src = canvas.toDataURL('image/jpeg')
await new Promise((resolve, reject) => {
imageElement.onload = resolve
imageElement.onerror = reject
})
} else {
imageElement = element
}
// Slice the image into columns
const columnImages = processImageFrame(imageElement, null, columnCount)
if (columnImages.length === 0) {
throw new Error('Failed to slice image into columns')
}
// Convert to base64 and prepare request
const columns = columnImages.map((imgData: ImageData, index: number) => ({
columnIndex: index,
imageData: imageDataToBase64Png(imgData),
}))
// Send to collect API
const response = await fetch('/api/vision-training/collect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
columns,
correctAnswer: value,
playerId: 'training-wizard',
sessionId: 'manual-capture',
}),
})
const result = await response.json()
if (result.success) {
setCaptureCount((c) => c + 1)
setLastCaptureStatus({
success: true,
message: `Saved ${result.savedCount} columns for "${value}"`,
})
setInputValue('')
// Focus input for next capture
inputRef.current?.focus()
onSamplesCollected()
} else {
throw new Error(result.error || 'Failed to save')
}
} catch (error) {
console.error('[TrainingDataCapture] Error:', error)
setLastCaptureStatus({
success: false,
message: error instanceof Error ? error.message : 'Failed to capture',
})
} finally {
setIsCapturing(false)
}
}, [inputValue, columnCount, onSamplesCollected])
// Handle keyboard shortcut (Enter to capture)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !isCapturing && inputValue) {
captureTrainingData()
}
},
[captureTrainingData, isCapturing, inputValue]
)
// Determine if capture is possible
const canCapture = cameraSource === 'local' || isPhoneConnected
return (
<div
data-component="training-data-capture"
className={css({
p: 3,
bg: 'blue.900/20',
border: '1px solid',
borderColor: 'blue.700/50',
borderRadius: 'lg',
})}
>
{/* Header */}
<div className={css({ display: 'flex', alignItems: 'center', gap: 2, mb: 3 })}>
<span>📸</span>
<span className={css({ fontWeight: 'medium', color: 'blue.300' })}>
Capture Training Data
</span>
{captureCount > 0 && (
<span className={css({ fontSize: 'xs', color: 'green.400', ml: 'auto' })}>
+{captureCount} this session
</span>
)}
</div>
{/* Camera capture component */}
<CameraCapture
initialSource="local"
onCapture={handleCapture}
onSourceChange={setCameraSource}
onPhoneConnected={setIsPhoneConnected}
compact
/>
{/* Capture controls - show when camera is ready */}
{canCapture && (
<div className={css({ mt: 3 })}>
<div className={css({ display: 'flex', gap: 2, mb: 2 })}>
<input
ref={inputRef}
type="number"
min="0"
placeholder={`Number (${columnCount} columns)`}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isCapturing}
className={css({
flex: 1,
px: 3,
py: 2,
bg: 'gray.700',
border: '1px solid',
borderColor: 'gray.600',
borderRadius: 'md',
color: 'gray.100',
fontSize: 'md',
fontFamily: 'mono',
_placeholder: { color: 'gray.500' },
_focus: { outline: 'none', borderColor: 'blue.500' },
_disabled: { opacity: 0.5 },
})}
/>
<button
type="button"
onClick={captureTrainingData}
disabled={isCapturing || !inputValue}
className={css({
px: 4,
py: 2,
bg: 'green.600',
color: 'white',
borderRadius: 'md',
border: 'none',
cursor: 'pointer',
fontWeight: 'medium',
whiteSpace: 'nowrap',
_hover: { bg: 'green.500' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
{isCapturing ? '...' : 'Capture'}
</button>
</div>
{/* Status message */}
{lastCaptureStatus && (
<div
className={css({
fontSize: 'sm',
color: lastCaptureStatus.success ? 'green.400' : 'red.400',
display: 'flex',
alignItems: 'center',
gap: 1,
})}
>
{lastCaptureStatus.success ? '✓' : '✗'} {lastCaptureStatus.message}
</div>
)}
</div>
)}
{/* Instructions */}
<div className={css({ fontSize: 'xs', color: 'gray.500', mt: 3 })}>
<p>1. Point camera at your abacus</p>
<p>2. Set beads to show a number</p>
<p>3. Type that number and press Capture (or Enter)</p>
</div>
</div>
)
}

View File

@ -8,6 +8,7 @@ import {
type CardId,
type SamplesData,
type HardwareInfo,
type PreflightInfo,
type TrainingConfig,
type ServerPhase,
type EpochData,
@ -24,6 +25,9 @@ interface CardCarouselProps {
hardwareInfo: HardwareInfo | null
hardwareLoading: boolean
fetchHardware: () => void
preflightInfo: PreflightInfo | null
preflightLoading: boolean
fetchPreflight: () => void
config: TrainingConfig
setConfig: (config: TrainingConfig | ((prev: TrainingConfig) => TrainingConfig)) => void
isGpu: boolean
@ -54,6 +58,9 @@ export function CardCarousel({
hardwareInfo,
hardwareLoading,
fetchHardware,
preflightInfo,
preflightLoading,
fetchPreflight,
config,
setConfig,
isGpu,
@ -107,6 +114,21 @@ export function CardCarousel({
}
return 'Detect HW'
case 'dependencies':
if (preflightInfo?.ready) {
return {
primary: 'Ready',
secondary: `${preflightInfo.dependencies.installed.length} packages`,
}
}
if (preflightInfo?.dependencies.missing.length) {
return {
primary: 'Missing',
secondary: `${preflightInfo.dependencies.missing.length} packages`,
}
}
return 'Check deps'
case 'config':
return {
primary: `${config.epochs} epochs`,
@ -175,6 +197,9 @@ export function CardCarousel({
hardwareInfo={hardwareInfo}
hardwareLoading={hardwareLoading}
fetchHardware={fetchHardware}
preflightInfo={preflightInfo}
preflightLoading={preflightLoading}
fetchPreflight={fetchPreflight}
config={config}
setConfig={setConfig}
isGpu={isGpu}

View File

@ -3,6 +3,7 @@
import { css } from '../../../../../../styled-system/css'
import { DataCard } from './cards/DataCard'
import { HardwareCard } from './cards/HardwareCard'
import { DependencyCard } from './cards/DependencyCard'
import { ConfigCard } from './cards/ConfigCard'
import { SetupCard } from './cards/SetupCard'
import { LoadingCard } from './cards/LoadingCard'
@ -14,6 +15,7 @@ import {
type CardId,
type SamplesData,
type HardwareInfo,
type PreflightInfo,
type TrainingConfig,
type ServerPhase,
type EpochData,
@ -29,6 +31,9 @@ interface ExpandedCardProps {
hardwareInfo: HardwareInfo | null
hardwareLoading: boolean
fetchHardware: () => void
preflightInfo: PreflightInfo | null
preflightLoading: boolean
fetchPreflight: () => void
config: TrainingConfig
setConfig: (config: TrainingConfig | ((prev: TrainingConfig) => TrainingConfig)) => void
isGpu: boolean
@ -56,6 +61,9 @@ export function ExpandedCard({
hardwareInfo,
hardwareLoading,
fetchHardware,
preflightInfo,
preflightLoading,
fetchPreflight,
config,
setConfig,
isGpu,
@ -95,6 +103,15 @@ export function ExpandedCard({
onProgress={onProgress}
/>
)
case 'dependencies':
return (
<DependencyCard
preflightInfo={preflightInfo}
preflightLoading={preflightLoading}
fetchPreflight={fetchPreflight}
onProgress={onProgress}
/>
)
case 'config':
return (
<ConfigCard

View File

@ -9,6 +9,7 @@ import {
type PhaseStatus,
type SamplesData,
type HardwareInfo,
type PreflightInfo,
type TrainingConfig,
type ServerPhase,
type EpochData,
@ -26,6 +27,9 @@ interface PhaseSectionProps {
hardwareInfo: HardwareInfo | null
hardwareLoading: boolean
fetchHardware: () => void
preflightInfo: PreflightInfo | null
preflightLoading: boolean
fetchPreflight: () => void
config: TrainingConfig
setConfig: (config: TrainingConfig | ((prev: TrainingConfig) => TrainingConfig)) => void
isGpu: boolean
@ -63,6 +67,9 @@ export function PhaseSection({
hardwareInfo,
hardwareLoading,
fetchHardware,
preflightInfo,
preflightLoading,
fetchPreflight,
config,
setConfig,
isGpu,
@ -165,6 +172,9 @@ export function PhaseSection({
hardwareInfo={hardwareInfo}
hardwareLoading={hardwareLoading}
fetchHardware={fetchHardware}
preflightInfo={preflightInfo}
preflightLoading={preflightLoading}
fetchPreflight={fetchPreflight}
config={config}
setConfig={setConfig}
isGpu={isGpu}

View File

@ -8,6 +8,7 @@ import {
serverPhaseToWizardPosition,
type SamplesData,
type HardwareInfo,
type PreflightInfo,
type TrainingConfig,
type ServerPhase,
type EpochData,
@ -24,6 +25,10 @@ interface TrainingWizardProps {
hardwareInfo: HardwareInfo | null
hardwareLoading: boolean
fetchHardware: () => void
// Preflight state
preflightInfo: PreflightInfo | null
preflightLoading: boolean
fetchPreflight: () => void
// Config state
config: TrainingConfig
setConfig: (config: TrainingConfig | ((prev: TrainingConfig) => TrainingConfig)) => void
@ -48,6 +53,9 @@ export function TrainingWizard({
hardwareInfo,
hardwareLoading,
fetchHardware,
preflightInfo,
preflightLoading,
fetchPreflight,
config,
setConfig,
serverPhase,
@ -131,6 +139,12 @@ export function TrainingWizard({
label: hardwareInfo.deviceType === 'gpu' ? 'GPU' : 'CPU',
value: hardwareInfo.deviceName.split(' ').slice(0, 2).join(' '),
}
case 'dependencies':
if (!preflightInfo?.ready) return null
return {
label: 'Packages',
value: `${preflightInfo.dependencies.installed.length}`,
}
case 'config':
return { label: 'Epochs', value: `${config.epochs}` }
case 'setup':
@ -171,6 +185,9 @@ export function TrainingWizard({
hardwareInfo={hardwareInfo}
hardwareLoading={hardwareLoading}
fetchHardware={fetchHardware}
preflightInfo={preflightInfo}
preflightLoading={preflightLoading}
fetchPreflight={fetchPreflight}
config={config}
setConfig={setConfig}
isGpu={isGpu}
@ -190,8 +207,10 @@ export function TrainingWizard({
onCancel={onCancel}
onTrainAgain={handleTrainAgain}
onSyncComplete={onSyncComplete}
// Validation
canStartTraining={!!hasEnoughData && !hardwareLoading && !hardwareInfo?.error}
// Validation - require data, hardware, and dependencies all ready
canStartTraining={
!!hasEnoughData && !hardwareLoading && !hardwareInfo?.error && !!preflightInfo?.ready
}
/>
))}
</div>

View File

@ -1,9 +1,9 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { css } from '../../../../../../../styled-system/css'
import type { SamplesData } from '../types'
import { TrainingDataCapture } from '../../TrainingDataCapture'
interface DataCardProps {
samples: SamplesData | null
@ -12,6 +12,14 @@ interface DataCardProps {
onSyncComplete?: () => void // Callback to refresh samples after sync
}
// Training data requirements
const REQUIREMENTS = {
minTotal: 50, // Minimum total images
minPerDigit: 3, // Minimum per digit
goodTotal: 200, // Good total
goodPerDigit: 10, // Good per digit
}
interface SyncStatus {
available: boolean
remote?: { host: string; totalImages: number }
@ -32,22 +40,79 @@ const QUALITY_CONFIG: Record<
{ color: string; label: string; barWidth: string }
> = {
none: { color: 'gray.500', label: 'No Data', barWidth: '0%' },
insufficient: { color: 'red.400', label: 'Need More', barWidth: '20%' },
insufficient: { color: 'red.400', label: 'Insufficient', barWidth: '20%' },
minimal: { color: 'yellow.400', label: 'Minimal', barWidth: '50%' },
good: { color: 'green.400', label: 'Good', barWidth: '80%' },
excellent: { color: 'green.300', label: 'Excellent', barWidth: '100%' },
}
/**
* Compute what's missing from the training data
*/
function computeRequirements(samples: SamplesData | null): {
needsMore: boolean
totalNeeded: number
missingDigits: number[]
message: string
} {
if (!samples || !samples.hasData) {
return {
needsMore: true,
totalNeeded: REQUIREMENTS.minTotal,
missingDigits: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
message: `Need ${REQUIREMENTS.minTotal}+ images total, ${REQUIREMENTS.minPerDigit}+ per digit`,
}
}
const digitCounts = Object.entries(samples.digits).map(([digit, data]) => ({
digit: parseInt(digit, 10),
count: data.count,
}))
const missingDigits = digitCounts
.filter((d) => d.count < REQUIREMENTS.minPerDigit)
.map((d) => d.digit)
const totalNeeded = Math.max(0, REQUIREMENTS.minTotal - samples.totalImages)
const needsMore = samples.totalImages < REQUIREMENTS.minTotal || missingDigits.length > 0
let message = ''
if (needsMore) {
const parts = []
if (totalNeeded > 0) {
parts.push(`${totalNeeded} more images`)
}
if (missingDigits.length > 0) {
parts.push(`digits ${missingDigits.join(', ')} need ${REQUIREMENTS.minPerDigit}+ each`)
}
message = `Need: ${parts.join('; ')}`
}
return { needsMore, totalNeeded, missingDigits, message }
}
export function DataCard({ samples, samplesLoading, onProgress, onSyncComplete }: DataCardProps) {
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null)
const [syncProgress, setSyncProgress] = useState<SyncProgress>({ phase: 'idle', message: '' })
const [syncChecking, setSyncChecking] = useState(true)
const [showCapture, setShowCapture] = useState(false)
const [showContinueWarning, setShowContinueWarning] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const requirements = computeRequirements(samples)
const isReady =
samples?.hasData && samples.dataQuality !== 'none' && samples.dataQuality !== 'insufficient'
const isSyncing = syncProgress.phase === 'connecting' || syncProgress.phase === 'syncing'
// Handle continue with insufficient data
const handleContinueAnyway = useCallback(() => {
if (!samples?.hasData) {
// Can't continue with zero data
return
}
onProgress()
}, [samples, onProgress])
// Check sync availability on mount
useEffect(() => {
const checkSync = async () => {
@ -324,27 +389,52 @@ export function DataCard({ samples, samplesLoading, onProgress, onSyncComplete }
</div>
)}
{/* No data state - only if sync not available */}
{!samples?.hasData && !showSyncUI && (
{/* Inline capture toggle - always show when not syncing */}
{!isSyncing && (
<div className={css({ mb: 4 })}>
<button
type="button"
onClick={() => setShowCapture(!showCapture)}
className={css({
display: 'flex',
alignItems: 'center',
gap: 2,
width: '100%',
py: 2,
px: 3,
bg: showCapture ? 'blue.700/30' : 'gray.700/50',
color: showCapture ? 'blue.300' : 'gray.300',
borderRadius: 'lg',
border: '1px solid',
borderColor: showCapture ? 'blue.600' : 'gray.600',
cursor: 'pointer',
fontSize: 'sm',
fontWeight: 'medium',
transition: 'all 0.2s',
_hover: { bg: showCapture ? 'blue.700/40' : 'gray.700' },
})}
>
<span>{showCapture ? '📸' : ''}</span>
<span>{showCapture ? 'Capturing...' : 'Capture Training Data'}</span>
<span className={css({ ml: 'auto', fontSize: 'xs', color: 'gray.500' })}>
{showCapture ? '▼' : '▶'}
</span>
</button>
{showCapture && (
<div className={css({ mt: 3 })}>
<TrainingDataCapture onSamplesCollected={() => onSyncComplete?.()} />
</div>
)}
</div>
)}
{/* No data state */}
{!samples?.hasData && !showCapture && (
<div className={css({ textAlign: 'center', py: 4 })}>
<div className={css({ fontSize: '2xl', mb: 2 })}>📷</div>
<div className={css({ color: 'gray.300', mb: 2 })}>No training data collected yet</div>
<Link
href="/vision-training"
className={css({
display: 'inline-block',
px: 4,
py: 2,
bg: 'blue.600',
color: 'white',
borderRadius: 'lg',
textDecoration: 'none',
fontWeight: 'medium',
_hover: { bg: 'blue.500' },
})}
>
Collect Training Data
</Link>
<div className={css({ fontSize: 'sm', color: 'gray.500' })}>{requirements.message}</div>
</div>
)}
@ -395,8 +485,10 @@ export function DataCard({ samples, samplesLoading, onProgress, onSyncComplete }
<div className={css({ fontSize: 'xs', color: 'gray.500', mb: 2 })}>Distribution</div>
<div className={css({ display: 'flex', gap: 1, justifyContent: 'space-between' })}>
{Object.entries(samples.digits).map(([digit, data]) => {
const digitNum = parseInt(digit, 10)
const maxCount = Math.max(...Object.values(samples.digits).map((d) => d.count))
const barHeight = maxCount > 0 ? (data.count / maxCount) * 30 : 0
const isMissing = requirements.missingDigits.includes(digitNum)
return (
<div
key={digit}
@ -410,13 +502,20 @@ export function DataCard({ samples, samplesLoading, onProgress, onSyncComplete }
<div
className={css({
width: '100%',
bg: 'blue.600',
bg: isMissing ? 'red.500' : 'blue.600',
borderRadius: 'sm',
transition: 'height 0.3s ease',
})}
style={{ height: `${barHeight}px` }}
style={{ height: `${Math.max(barHeight, isMissing ? 4 : 0)}px` }}
/>
<span className={css({ fontSize: 'xs', color: 'gray.500', mt: 1 })}>
<span
className={css({
fontSize: 'xs',
color: isMissing ? 'red.400' : 'gray.500',
mt: 1,
fontWeight: isMissing ? 'bold' : 'normal',
})}
>
{digit}
</span>
</div>
@ -425,31 +524,131 @@ export function DataCard({ samples, samplesLoading, onProgress, onSyncComplete }
</div>
</div>
{/* Ready indicator and continue */}
{isReady && !isSyncing && (
<div className={css({ mt: 4 })}>
<div className={css({ display: 'flex', alignItems: 'center', gap: 2, mb: 3 })}>
<span className={css({ color: 'green.400' })}></span>
<span className={css({ color: 'green.400', fontSize: 'sm' })}>Ready to train</span>
</div>
{/* Requirements message when insufficient */}
{requirements.needsMore && (
<div
className={css({
mb: 3,
p: 2,
bg: 'yellow.900/30',
border: '1px solid',
borderColor: 'yellow.700/50',
borderRadius: 'md',
fontSize: 'sm',
color: 'yellow.300',
})}
>
{requirements.message}
</div>
)}
<button
type="button"
onClick={onProgress}
className={css({
width: '100%',
py: 2,
bg: 'green.600',
color: 'white',
borderRadius: 'lg',
border: 'none',
cursor: 'pointer',
fontWeight: 'medium',
_hover: { bg: 'green.500' },
})}
>
Continue
</button>
{/* Ready indicator and continue */}
{!isSyncing && (
<div className={css({ mt: 4 })}>
{isReady ? (
<>
<div className={css({ display: 'flex', alignItems: 'center', gap: 2, mb: 3 })}>
<span className={css({ color: 'green.400' })}></span>
<span className={css({ color: 'green.400', fontSize: 'sm' })}>
Ready to train
</span>
</div>
<button
type="button"
onClick={onProgress}
className={css({
width: '100%',
py: 2,
bg: 'green.600',
color: 'white',
borderRadius: 'lg',
border: 'none',
cursor: 'pointer',
fontWeight: 'medium',
_hover: { bg: 'green.500' },
})}
>
Continue
</button>
</>
) : (
<>
{/* Continue anyway with warning */}
{!showContinueWarning ? (
<button
type="button"
onClick={() => setShowContinueWarning(true)}
className={css({
width: '100%',
py: 2,
bg: 'transparent',
color: 'gray.400',
borderRadius: 'lg',
border: '1px solid',
borderColor: 'gray.600',
cursor: 'pointer',
fontSize: 'sm',
_hover: { borderColor: 'gray.500', color: 'gray.300' },
})}
>
Continue anyway...
</button>
) : (
<div
className={css({
p: 3,
bg: 'red.900/30',
border: '1px solid',
borderColor: 'red.700/50',
borderRadius: 'lg',
})}
>
<div className={css({ fontSize: 'sm', color: 'red.300', mb: 3 })}>
<strong>Warning:</strong> Training with insufficient data may produce a
poor model. Results may be inaccurate.
</div>
<div className={css({ display: 'flex', gap: 2 })}>
<button
type="button"
onClick={() => setShowContinueWarning(false)}
className={css({
flex: 1,
py: 2,
bg: 'transparent',
color: 'gray.400',
borderRadius: 'md',
border: '1px solid',
borderColor: 'gray.600',
cursor: 'pointer',
fontSize: 'sm',
_hover: { borderColor: 'gray.500' },
})}
>
Cancel
</button>
<button
type="button"
onClick={handleContinueAnyway}
className={css({
flex: 1,
py: 2,
bg: 'red.700',
color: 'white',
borderRadius: 'md',
border: 'none',
cursor: 'pointer',
fontSize: 'sm',
fontWeight: 'medium',
_hover: { bg: 'red.600' },
})}
>
Continue Anyway
</button>
</div>
</div>
)}
</>
)}
</div>
)}
</>

View File

@ -0,0 +1,379 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { css } from '../../../../../../../styled-system/css'
import type { PreflightInfo } from '../types'
interface DependencyCardProps {
preflightInfo: PreflightInfo | null
preflightLoading: boolean
fetchPreflight: () => void
onProgress: () => void
}
const AUTO_PROGRESS_DELAY = 2000
export function DependencyCard({
preflightInfo,
preflightLoading,
fetchPreflight,
onProgress,
}: DependencyCardProps) {
const [countdown, setCountdown] = useState(AUTO_PROGRESS_DELAY)
const timerRef = useRef<NodeJS.Timeout | null>(null)
const isReady = preflightInfo?.ready && !preflightLoading
// Auto-progress countdown
useEffect(() => {
if (!isReady) {
setCountdown(AUTO_PROGRESS_DELAY)
return
}
// Start countdown
const startTime = Date.now()
const tick = () => {
const elapsed = Date.now() - startTime
const remaining = Math.max(0, AUTO_PROGRESS_DELAY - elapsed)
setCountdown(remaining)
if (remaining <= 0) {
onProgress()
} else {
timerRef.current = setTimeout(tick, 50)
}
}
timerRef.current = setTimeout(tick, 50)
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
}
}, [isReady, onProgress])
const handleSkip = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
onProgress()
}, [onProgress])
const handleRetry = useCallback(() => {
fetchPreflight()
}, [fetchPreflight])
if (preflightLoading) {
return (
<div
data-component="dependency-card"
data-status="loading"
className={css({ textAlign: 'center', py: 6 })}
>
<div className={css({ fontSize: '2xl', mb: 2, animation: 'spin 1s linear infinite' })}>
📦
</div>
<div className={css({ color: 'gray.400' })}>Checking dependencies...</div>
</div>
)
}
// Show error if there's a platform or venv issue
if (preflightInfo && (!preflightInfo.platform.supported || preflightInfo.venv.error)) {
const errorMessage = preflightInfo.platform.reason || preflightInfo.venv.error
return (
<div
data-component="dependency-card"
data-status="error"
className={css({ textAlign: 'center', py: 4 })}
>
<div className={css({ fontSize: '2xl', mb: 2 })}>🚫</div>
<div className={css({ color: 'red.400', mb: 2, fontWeight: 'medium' })}>
Environment Error
</div>
<div className={css({ fontSize: 'sm', color: 'gray.400', mb: 4, px: 2 })}>
{errorMessage}
</div>
<button
type="button"
onClick={handleRetry}
className={css({
px: 4,
py: 2,
bg: 'blue.600',
color: 'white',
borderRadius: 'lg',
border: 'none',
cursor: 'pointer',
_hover: { bg: 'blue.500' },
})}
>
Retry
</button>
</div>
)
}
// Show missing dependencies
if (preflightInfo && !preflightInfo.dependencies.allInstalled) {
const { missing, installed, error } = preflightInfo.dependencies
return (
<div
data-component="dependency-card"
data-status="missing"
className={css({ textAlign: 'center', py: 4 })}
>
<div className={css({ fontSize: '2xl', mb: 2 })}></div>
<div className={css({ color: 'yellow.400', mb: 2, fontWeight: 'medium' })}>
Missing Dependencies
</div>
{error && (
<div className={css({ fontSize: 'sm', color: 'red.400', mb: 3, px: 2 })}>{error}</div>
)}
{/* Missing packages */}
<div className={css({ mb: 4 })}>
<div
className={css({
fontSize: 'xs',
color: 'gray.500',
mb: 2,
textTransform: 'uppercase',
})}
>
Missing ({missing.length})
</div>
<div
className={css({ display: 'flex', flexWrap: 'wrap', gap: 1, justifyContent: 'center' })}
>
{missing.map((pkg) => (
<span
key={pkg.pipName}
className={css({
px: 2,
py: 0.5,
bg: 'red.900/50',
color: 'red.300',
borderRadius: 'md',
fontSize: 'xs',
fontFamily: 'mono',
})}
>
{pkg.name}
</span>
))}
</div>
</div>
{/* Installed packages */}
{installed.length > 0 && (
<div className={css({ mb: 4 })}>
<div
className={css({
fontSize: 'xs',
color: 'gray.500',
mb: 2,
textTransform: 'uppercase',
})}
>
Installed ({installed.length})
</div>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: 1,
justifyContent: 'center',
})}
>
{installed.map((pkg) => (
<span
key={pkg.pipName}
className={css({
px: 2,
py: 0.5,
bg: 'green.900/50',
color: 'green.300',
borderRadius: 'md',
fontSize: 'xs',
fontFamily: 'mono',
})}
>
{pkg.name}
</span>
))}
</div>
</div>
)}
{/* Instructions */}
<div className={css({ fontSize: 'xs', color: 'gray.500', mb: 4, px: 4 })}>
Install missing packages manually, then retry:
<div
className={css({
fontFamily: 'mono',
mt: 1,
color: 'gray.400',
fontSize: 'xs',
wordBreak: 'break-all',
})}
>
pip install {missing.map((p) => p.pipName).join(' ')}
</div>
</div>
<button
type="button"
onClick={handleRetry}
className={css({
px: 4,
py: 2,
bg: 'blue.600',
color: 'white',
borderRadius: 'lg',
border: 'none',
cursor: 'pointer',
_hover: { bg: 'blue.500' },
})}
>
Check Again
</button>
</div>
)
}
if (!preflightInfo) {
return (
<div
data-component="dependency-card"
data-status="empty"
className={css({ textAlign: 'center', py: 6 })}
>
<div className={css({ color: 'gray.500' })}>No dependency info</div>
<button
type="button"
onClick={handleRetry}
className={css({
mt: 3,
px: 4,
py: 2,
bg: 'blue.600',
color: 'white',
borderRadius: 'lg',
border: 'none',
cursor: 'pointer',
_hover: { bg: 'blue.500' },
})}
>
Check Dependencies
</button>
</div>
)
}
// All dependencies installed!
const { installed } = preflightInfo.dependencies
const progressPercent = ((AUTO_PROGRESS_DELAY - countdown) / AUTO_PROGRESS_DELAY) * 100
return (
<div
data-component="dependency-card"
data-status="ready"
className={css({ textAlign: 'center' })}
>
{/* Success Icon */}
<div className={css({ fontSize: '3xl', mb: 2 })}></div>
{/* Title */}
<div className={css({ fontSize: 'xl', fontWeight: 'bold', color: 'gray.100', mb: 1 })}>
All Dependencies Ready
</div>
{/* Badge */}
<div
className={css({
display: 'inline-block',
px: 3,
py: 1,
borderRadius: 'full',
fontSize: 'sm',
fontWeight: 'bold',
bg: 'green.700',
color: 'white',
mb: 3,
})}
>
{installed.length} packages installed
</div>
{/* Installed packages list */}
<div className={css({ mb: 4 })}>
<div
className={css({ display: 'flex', flexWrap: 'wrap', gap: 1, justifyContent: 'center' })}
>
{installed.map((pkg) => (
<span
key={pkg.pipName}
className={css({
px: 2,
py: 0.5,
bg: 'green.900/50',
color: 'green.300',
borderRadius: 'md',
fontSize: 'xs',
fontFamily: 'mono',
})}
>
{pkg.name}
</span>
))}
</div>
</div>
{/* Auto-progress bar */}
<div className={css({ mb: 3 })}>
<div
className={css({
height: '4px',
bg: 'gray.700',
borderRadius: 'full',
overflow: 'hidden',
})}
>
<div
className={css({
height: '100%',
bg: 'green.500',
borderRadius: 'full',
transition: 'width 0.05s linear',
})}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
{/* Continue button */}
<button
type="button"
onClick={handleSkip}
className={css({
width: '100%',
py: 2,
bg: 'green.600',
color: 'white',
borderRadius: 'lg',
border: 'none',
cursor: 'pointer',
fontWeight: 'medium',
_hover: { bg: 'green.500' },
})}
>
Continue
</button>
</div>
)
}

View File

@ -3,6 +3,7 @@ export type PhaseId = 'preparation' | 'training' | 'results'
export type CardId =
| 'data'
| 'hardware'
| 'dependencies'
| 'config'
| 'setup'
| 'loading'
@ -28,7 +29,7 @@ export const PHASES: PhaseDefinition[] = [
{
id: 'preparation',
title: 'Preparation',
cards: ['data', 'hardware', 'config'],
cards: ['data', 'hardware', 'dependencies', 'config'],
},
{
id: 'training',
@ -66,6 +67,13 @@ export const CARDS: Record<CardId, CardDefinition> = {
autoProgress: true,
autoProgressDelay: 2000,
},
dependencies: {
id: 'dependencies',
title: 'Dependencies',
icon: '📦',
autoProgress: true,
autoProgressDelay: 2000,
},
config: {
id: 'config',
title: 'Configuration',
@ -140,6 +148,27 @@ export interface HardwareInfo {
hint?: string
}
export interface PreflightInfo {
ready: boolean
platform: {
supported: boolean
reason?: string
}
venv: {
exists: boolean
python: string
isAppleSilicon: boolean
hasGpu: boolean
error?: string
}
dependencies: {
allInstalled: boolean
installed: { name: string; pipName: string }[]
missing: { name: string; pipName: string }[]
error?: string
}
}
export interface EpochData {
epoch: number
total_epochs: number

View File

@ -7,6 +7,7 @@ import { TrainingWizard } from './components/wizard/TrainingWizard'
import type {
SamplesData,
HardwareInfo,
PreflightInfo,
ServerPhase,
TrainingConfig,
EpochData,
@ -27,6 +28,10 @@ export default function TrainModelPage() {
const [hardwareInfo, setHardwareInfo] = useState<HardwareInfo | null>(null)
const [hardwareLoading, setHardwareLoading] = useState(true)
// Preflight/dependency info
const [preflightInfo, setPreflightInfo] = useState<PreflightInfo | null>(null)
const [preflightLoading, setPreflightLoading] = useState(true)
// Training state
const [serverPhase, setServerPhase] = useState<ServerPhase>('idle')
const [statusMessage, setStatusMessage] = useState<string>('')
@ -79,10 +84,36 @@ export default function TrainModelPage() {
}
}, [])
// Fetch preflight/dependency info
const fetchPreflight = useCallback(async () => {
setPreflightLoading(true)
setPreflightInfo(null)
try {
const response = await fetch('/api/vision-training/preflight')
const data = await response.json()
setPreflightInfo(data)
} catch {
setPreflightInfo({
ready: false,
platform: { supported: false, reason: 'Failed to check dependencies' },
venv: { exists: false, python: '', isAppleSilicon: false, hasGpu: false },
dependencies: {
allInstalled: false,
installed: [],
missing: [],
error: 'Failed to fetch',
},
})
} finally {
setPreflightLoading(false)
}
}, [])
useEffect(() => {
fetchHardware()
fetchSamples()
}, [fetchHardware, fetchSamples])
fetchPreflight()
}, [fetchHardware, fetchSamples, fetchPreflight])
useEffect(() => {
return () => {
@ -308,6 +339,9 @@ export default function TrainModelPage() {
hardwareInfo={hardwareInfo}
hardwareLoading={hardwareLoading}
fetchHardware={fetchHardware}
preflightInfo={preflightInfo}
preflightLoading={preflightLoading}
fetchPreflight={fetchPreflight}
config={config}
setConfig={setConfig}
serverPhase={serverPhase}

View File

@ -0,0 +1,568 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useDeskViewCamera } from '@/hooks/useDeskViewCamera'
import { useRemoteCameraDesktop } from '@/hooks/useRemoteCameraDesktop'
import { css } from '../../../styled-system/css'
import { RemoteCameraQRCode } from './RemoteCameraQRCode'
import { VisionCameraFeed } from './VisionCameraFeed'
export type CameraSource = 'local' | 'phone'
export interface CameraCaptureProps {
/** Initial camera source (default: 'local') */
initialSource?: CameraSource
/** Called when a frame is captured (either from local video or phone) */
onCapture?: (imageElement: HTMLImageElement | HTMLVideoElement) => void
/** Called when camera source changes */
onSourceChange?: (source: CameraSource) => void
/** Called when phone connection status changes */
onPhoneConnected?: (connected: boolean) => void
/** Show source selector tabs (default: true) */
showSourceSelector?: boolean
/** Compact mode - smaller QR code, less padding */
compact?: boolean
}
/**
* CameraCapture - Reusable component for capturing frames from local or phone camera
*
* Provides:
* - Camera source tabs (local/phone)
* - Local camera feed with device selection
* - Phone camera via QR code connection
* - Access to current frame for capture
*
* Used by:
* - TrainingDataCapture (vision training wizard)
* - AbacusVisionBridge (practice sessions) - uses separate implementation for calibration
*/
export function CameraCapture({
initialSource = 'local',
onCapture,
onSourceChange,
onPhoneConnected,
showSourceSelector = true,
compact = false,
}: CameraCaptureProps) {
const [cameraSource, setCameraSource] = useState<CameraSource>(initialSource)
// Local camera
const camera = useDeskViewCamera()
const videoRef = useRef<HTMLVideoElement | null>(null)
// Phone camera
const {
isPhoneConnected,
latestFrame,
frameRate,
currentSessionId,
subscribe,
unsubscribe,
getPersistedSessionId,
clearSession,
} = useRemoteCameraDesktop()
const [sessionId, setSessionId] = useState<string | null>(null)
const imageRef = useRef<HTMLImageElement | null>(null)
// Notify phone connection changes
useEffect(() => {
if (cameraSource === 'phone') {
onPhoneConnected?.(isPhoneConnected)
}
}, [cameraSource, isPhoneConnected, onPhoneConnected])
// Start local camera when source is local
useEffect(() => {
if (cameraSource === 'local') {
camera.requestCamera()
}
return () => {
if (cameraSource === 'local') {
camera.stopCamera()
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cameraSource])
// Handle camera source change
const handleSourceChange = useCallback(
(source: CameraSource) => {
if (source === cameraSource) return
setCameraSource(source)
onSourceChange?.(source)
if (source === 'local') {
// Stop phone camera, start local
if (currentSessionId) {
unsubscribe()
}
camera.requestCamera()
} else {
// Stop local camera
camera.stopCamera()
// Check for persisted session
const persistedSessionId = getPersistedSessionId()
if (persistedSessionId) {
setSessionId(persistedSessionId)
}
}
},
[cameraSource, currentSessionId, unsubscribe, camera, getPersistedSessionId, onSourceChange]
)
// Handle session created by QR code
const handleSessionCreated = useCallback(
(newSessionId: string) => {
setSessionId(newSessionId)
subscribe(newSessionId)
},
[subscribe]
)
// Subscribe when session is set and source is phone
useEffect(() => {
if (sessionId && cameraSource === 'phone' && !currentSessionId) {
subscribe(sessionId)
}
}, [sessionId, cameraSource, currentSessionId, subscribe])
// Cleanup on unmount
useEffect(() => {
return () => {
if (currentSessionId) {
unsubscribe()
}
}
}, [currentSessionId, unsubscribe])
// Start fresh phone session
const handleStartFreshSession = useCallback(() => {
clearSession()
setSessionId(null)
}, [clearSession])
// Video ref callback for VisionCameraFeed - also calls onCapture
const handleVideoRef = useCallback(
(el: HTMLVideoElement | null) => {
videoRef.current = el
if (el && onCapture) {
onCapture(el)
}
},
[onCapture]
)
// Image ref callback - also calls onCapture
const handleImageRef = useCallback(
(el: HTMLImageElement | null) => {
imageRef.current = el
if (el && onCapture) {
onCapture(el)
}
},
[onCapture]
)
// Update capture element when phone frame changes
useEffect(() => {
if (
cameraSource === 'phone' &&
isPhoneConnected &&
latestFrame &&
imageRef.current &&
onCapture
) {
onCapture(imageRef.current)
}
}, [cameraSource, isPhoneConnected, latestFrame, onCapture])
// Determine if we have a usable frame
const hasFrame =
cameraSource === 'local'
? camera.videoStream !== null
: isPhoneConnected && latestFrame !== null
return (
<div
data-component="camera-capture"
data-source={cameraSource}
className={css({
display: 'flex',
flexDirection: 'column',
gap: compact ? 2 : 3,
})}
>
{/* Camera source selector tabs */}
{showSourceSelector && (
<div
data-element="source-selector"
className={css({
display: 'flex',
gap: 0,
bg: 'gray.800',
borderRadius: 'lg',
p: 1,
})}
>
<button
type="button"
data-source="local"
data-active={cameraSource === 'local' ? 'true' : 'false'}
onClick={() => handleSourceChange('local')}
className={css({
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
py: 2,
px: 3,
bg: cameraSource === 'local' ? 'gray.700' : 'transparent',
color: cameraSource === 'local' ? 'white' : 'gray.400',
border: 'none',
borderRadius: 'md',
cursor: 'pointer',
fontSize: 'sm',
fontWeight: cameraSource === 'local' ? 'medium' : 'normal',
transition: 'all 0.15s',
_hover: {
color: 'white',
bg: cameraSource === 'local' ? 'gray.700' : 'gray.750',
},
})}
>
<span>💻</span>
<span>This Device</span>
</button>
<button
type="button"
data-source="phone"
data-active={cameraSource === 'phone' ? 'true' : 'false'}
onClick={() => handleSourceChange('phone')}
className={css({
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
py: 2,
px: 3,
bg: cameraSource === 'phone' ? 'gray.700' : 'transparent',
color: cameraSource === 'phone' ? 'white' : 'gray.400',
border: 'none',
borderRadius: 'md',
cursor: 'pointer',
fontSize: 'sm',
fontWeight: cameraSource === 'phone' ? 'medium' : 'normal',
transition: 'all 0.15s',
_hover: {
color: 'white',
bg: cameraSource === 'phone' ? 'gray.700' : 'gray.750',
},
})}
>
<span>📱</span>
<span>Phone Camera</span>
</button>
</div>
)}
{/* Camera feed */}
<div
data-element="camera-feed"
className={css({
position: 'relative',
borderRadius: 'lg',
overflow: 'hidden',
bg: 'gray.800',
minHeight: '150px',
})}
>
{cameraSource === 'local' ? (
/* Local camera feed */
<>
<VisionCameraFeed
videoStream={camera.videoStream}
isLoading={camera.isLoading}
videoRef={handleVideoRef}
/>
{/* Local camera toolbar */}
{camera.videoStream && (
<div
data-element="feed-toolbar"
className={css({
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
p: 2,
bg: 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(4px)',
})}
>
{/* Camera selector */}
{camera.availableDevices.length > 0 && (
<select
data-element="camera-selector"
value={camera.currentDevice?.deviceId ?? ''}
onChange={(e) => camera.requestCamera(e.target.value)}
className={css({
flex: 1,
py: 1.5,
px: 2,
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
border: '1px solid',
borderColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: 'md',
fontSize: 'xs',
maxWidth: '180px',
cursor: 'pointer',
})}
>
{camera.availableDevices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label || `Camera ${device.deviceId.slice(0, 8)}`}
</option>
))}
</select>
)}
{/* Toolbar buttons */}
<div className={css({ display: 'flex', gap: 1 })}>
{camera.availableDevices.length > 1 && (
<button
type="button"
onClick={() => camera.flipCamera()}
data-action="flip-camera"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
bg: 'rgba(255, 255, 255, 0.15)',
color: 'white',
border: 'none',
borderRadius: 'md',
cursor: 'pointer',
fontSize: 'sm',
_hover: { bg: 'rgba(255, 255, 255, 0.25)' },
})}
title="Switch camera"
>
🔄
</button>
)}
{camera.isTorchAvailable && (
<button
type="button"
onClick={() => camera.toggleTorch()}
data-action="toggle-torch"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
bg: camera.isTorchOn ? 'yellow.600' : 'rgba(255, 255, 255, 0.15)',
color: 'white',
border: 'none',
borderRadius: 'md',
cursor: 'pointer',
fontSize: 'sm',
_hover: {
bg: camera.isTorchOn ? 'yellow.500' : 'rgba(255, 255, 255, 0.25)',
},
})}
title={camera.isTorchOn ? 'Turn off flash' : 'Turn on flash'}
>
{camera.isTorchOn ? '🔦' : '💡'}
</button>
)}
</div>
</div>
)}
{/* Camera error */}
{camera.error && (
<div
className={css({
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 4,
bg: 'red.900/80',
color: 'red.200',
fontSize: 'sm',
textAlign: 'center',
})}
>
{camera.error}
</div>
)}
</>
) : /* Phone camera feed */ !sessionId ? (
/* Show QR code to connect phone */
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: compact ? 4 : 6,
})}
>
<RemoteCameraQRCode
onSessionCreated={handleSessionCreated}
size={compact ? 140 : 180}
/>
<p
className={css({
color: 'gray.400',
fontSize: 'sm',
textAlign: 'center',
mt: 3,
})}
>
Scan with your phone to connect
</p>
</div>
) : !isPhoneConnected ? (
/* Waiting for phone */
<div
className={css({
aspectRatio: '4/3',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 3,
p: 4,
})}
>
<RemoteCameraQRCode
onSessionCreated={handleSessionCreated}
existingSessionId={sessionId}
size={compact ? 120 : 150}
/>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: 1.5,
color: 'gray.400',
fontSize: 'xs',
})}
>
<span
className={css({
width: 1.5,
height: 1.5,
borderRadius: 'full',
bg: 'gray.500',
animation: 'pulse 1.5s infinite',
})}
/>
Waiting for phone
<span className={css({ color: 'gray.600' })}>·</span>
<button
type="button"
onClick={handleStartFreshSession}
className={css({
color: 'gray.500',
bg: 'transparent',
border: 'none',
cursor: 'pointer',
fontSize: 'xs',
textDecoration: 'underline',
_hover: { color: 'gray.300' },
})}
>
new session
</button>
<style>{`@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }`}</style>
</div>
</div>
) : (
/* Show camera frames */
<div className={css({ position: 'relative' })}>
{latestFrame ? (
<img
ref={handleImageRef}
src={`data:image/jpeg;base64,${latestFrame.imageData}`}
alt="Remote camera view"
className={css({
width: '100%',
height: 'auto',
display: 'block',
})}
/>
) : (
<div
className={css({
width: '100%',
aspectRatio: '4/3',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'gray.400',
fontSize: 'sm',
})}
>
Waiting for frames...
</div>
)}
{/* Phone camera toolbar */}
<div
data-element="feed-toolbar"
className={css({
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
p: 2,
bg: 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(4px)',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: 2,
fontSize: 'xs',
color: 'white',
})}
>
<span
className={css({
width: 2,
height: 2,
borderRadius: 'full',
bg: 'green.500',
})}
/>
{frameRate} fps
</div>
</div>
</div>
)}
</div>
</div>
)
}