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:
parent
0e1e7cf25d
commit
47a98f1bb7
|
|
@ -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')))"`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue