feat(scanner): add camera controls, auto presets, and persisted settings

- Add torch/flashlight toggle (when available)
- Add camera flip button (when multiple cameras)
- Add lighting preset selector with auto-detection based on frame analysis
- Add experimental finger occlusion mode for better edge detection
- Persist scanner settings per-user in database
- Add responsive ScannerControlsDrawer with compact mobile layout
- Hide sublabels on small screens, show on tablets+

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2026-01-12 11:12:15 -06:00
parent fb0abcef27
commit bc02ba281d
12 changed files with 3694 additions and 160 deletions

View File

@ -0,0 +1,13 @@
-- Custom SQL migration file, put your code below! --
CREATE TABLE `scanner_settings` (
`user_id` text PRIMARY KEY NOT NULL,
`preprocessing` text DEFAULT 'multi' NOT NULL,
`enable_histogram_equalization` integer DEFAULT true NOT NULL,
`enable_adaptive_threshold` integer DEFAULT true NOT NULL,
`enable_morph_gradient` integer DEFAULT true NOT NULL,
`canny_low` integer DEFAULT 50 NOT NULL,
`canny_high` integer DEFAULT 150 NOT NULL,
`adaptive_block_size` integer DEFAULT 11 NOT NULL,
`adaptive_c` real DEFAULT 2 NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);

View File

@ -0,0 +1,2 @@
-- Custom SQL migration file, put your code below! --
ALTER TABLE `scanner_settings` ADD `enable_hough_lines` integer DEFAULT true NOT NULL;

View File

@ -116,13 +116,9 @@
"abacus_settings_user_id_users_id_fk": {
"name": "abacus_settings_user_id_users_id_fk",
"tableFrom": "abacus_settings",
"columnsFrom": [
"user_id"
],
"columnsFrom": ["user_id"],
"tableTo": "users",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -240,9 +236,7 @@
"indexes": {
"arcade_rooms_code_unique": {
"name": "arcade_rooms_code_unique",
"columns": [
"code"
],
"columns": ["code"],
"isUnique": true
}
},
@ -339,26 +333,18 @@
"arcade_sessions_room_id_arcade_rooms_id_fk": {
"name": "arcade_sessions_room_id_arcade_rooms_id_fk",
"tableFrom": "arcade_sessions",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"arcade_sessions_user_id_users_id_fk": {
"name": "arcade_sessions_user_id_users_id_fk",
"tableFrom": "arcade_sessions",
"columnsFrom": [
"user_id"
],
"columnsFrom": ["user_id"],
"tableTo": "users",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -424,9 +410,7 @@
"indexes": {
"players_user_id_idx": {
"name": "players_user_id_idx",
"columns": [
"user_id"
],
"columns": ["user_id"],
"isUnique": false
}
},
@ -434,13 +418,9 @@
"players_user_id_users_id_fk": {
"name": "players_user_id_users_id_fk",
"tableFrom": "players",
"columnsFrom": [
"user_id"
],
"columnsFrom": ["user_id"],
"tableTo": "users",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -514,9 +494,7 @@
"indexes": {
"idx_room_members_user_id_unique": {
"name": "idx_room_members_user_id_unique",
"columns": [
"user_id"
],
"columns": ["user_id"],
"isUnique": true
}
},
@ -524,13 +502,9 @@
"room_members_room_id_arcade_rooms_id_fk": {
"name": "room_members_room_id_arcade_rooms_id_fk",
"tableFrom": "room_members",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -605,13 +579,9 @@
"room_member_history_room_id_arcade_rooms_id_fk": {
"name": "room_member_history_room_id_arcade_rooms_id_fk",
"tableFrom": "room_member_history",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -713,10 +683,7 @@
"indexes": {
"idx_room_invitations_user_room": {
"name": "idx_room_invitations_user_room",
"columns": [
"user_id",
"room_id"
],
"columns": ["user_id", "room_id"],
"isUnique": true
}
},
@ -724,13 +691,9 @@
"room_invitations_room_id_arcade_rooms_id_fk": {
"name": "room_invitations_room_id_arcade_rooms_id_fk",
"tableFrom": "room_invitations",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -833,13 +796,9 @@
"room_reports_room_id_arcade_rooms_id_fk": {
"name": "room_reports_room_id_arcade_rooms_id_fk",
"tableFrom": "room_reports",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -918,10 +877,7 @@
"indexes": {
"idx_room_bans_user_room": {
"name": "idx_room_bans_user_room",
"columns": [
"user_id",
"room_id"
],
"columns": ["user_id", "room_id"],
"isUnique": true
}
},
@ -929,13 +885,9 @@
"room_bans_room_id_arcade_rooms_id_fk": {
"name": "room_bans_room_id_arcade_rooms_id_fk",
"tableFrom": "room_bans",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -998,13 +950,9 @@
"user_stats_user_id_users_id_fk": {
"name": "user_stats_user_id_users_id_fk",
"tableFrom": "user_stats",
"columnsFrom": [
"user_id"
],
"columnsFrom": ["user_id"],
"tableTo": "users",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -1062,16 +1010,12 @@
"indexes": {
"users_guest_id_unique": {
"name": "users_guest_id_unique",
"columns": [
"guest_id"
],
"columns": ["guest_id"],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"columns": ["email"],
"isUnique": true
}
},
@ -1091,4 +1035,4 @@
"internal": {
"indexes": {}
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -442,6 +442,20 @@
"when": 1768066927761,
"tag": "0062_confused_captain_midlands",
"breakpoints": true
},
{
"idx": 63,
"version": "6",
"when": 1768235998923,
"tag": "0063_unique_moondragon",
"breakpoints": true
},
{
"idx": 64,
"version": "6",
"when": 1768236515704,
"tag": "0064_thin_marvel_apes",
"breakpoints": true
}
]
}
}

View File

@ -0,0 +1,165 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db } from '@/db'
import * as schema from '@/db/schema'
import { getViewerId } from '@/lib/viewer'
/**
* GET /api/scanner-settings
* Fetch scanner settings for the current user
*/
export async function GET() {
try {
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
// Find or create scanner settings
let settings = await db.query.scannerSettings.findFirst({
where: eq(schema.scannerSettings.userId, user.id),
})
// If no settings exist, create with defaults
if (!settings) {
const [newSettings] = await db
.insert(schema.scannerSettings)
.values({ userId: user.id })
.returning()
settings = newSettings
}
// Transform database format to QuadDetectorConfig format
const config = {
preprocessing: settings.preprocessing,
enableHistogramEqualization: settings.enableHistogramEqualization,
enableAdaptiveThreshold: settings.enableAdaptiveThreshold,
enableMorphGradient: settings.enableMorphGradient,
cannyThresholds: [settings.cannyLow, settings.cannyHigh] as [number, number],
adaptiveBlockSize: settings.adaptiveBlockSize,
adaptiveC: settings.adaptiveC,
enableHoughLines: settings.enableHoughLines,
}
return NextResponse.json({ settings: config })
} catch (error) {
console.error('Failed to fetch scanner settings:', error)
return NextResponse.json({ error: 'Failed to fetch scanner settings' }, { status: 500 })
}
}
/**
* PATCH /api/scanner-settings
* Update scanner settings for the current user
*/
export async function PATCH(req: NextRequest) {
try {
const viewerId = await getViewerId()
// Handle empty or invalid JSON body gracefully
let body: Record<string, unknown>
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid or empty request body' }, { status: 400 })
}
// Security: Strip userId from request body - it must come from session only
const { userId: _, ...updates } = body
// Transform QuadDetectorConfig format to database format
const dbUpdates: Record<string, unknown> = {}
if (updates.preprocessing !== undefined) {
dbUpdates.preprocessing = updates.preprocessing
}
if (updates.enableHistogramEqualization !== undefined) {
dbUpdates.enableHistogramEqualization = updates.enableHistogramEqualization
}
if (updates.enableAdaptiveThreshold !== undefined) {
dbUpdates.enableAdaptiveThreshold = updates.enableAdaptiveThreshold
}
if (updates.enableMorphGradient !== undefined) {
dbUpdates.enableMorphGradient = updates.enableMorphGradient
}
if (updates.cannyThresholds !== undefined) {
const thresholds = updates.cannyThresholds as [number, number]
dbUpdates.cannyLow = thresholds[0]
dbUpdates.cannyHigh = thresholds[1]
}
if (updates.adaptiveBlockSize !== undefined) {
dbUpdates.adaptiveBlockSize = updates.adaptiveBlockSize
}
if (updates.adaptiveC !== undefined) {
dbUpdates.adaptiveC = updates.adaptiveC
}
if (updates.enableHoughLines !== undefined) {
dbUpdates.enableHoughLines = updates.enableHoughLines
}
const user = await getOrCreateUser(viewerId)
// Ensure settings exist
const existingSettings = await db.query.scannerSettings.findFirst({
where: eq(schema.scannerSettings.userId, user.id),
})
let resultSettings: schema.ScannerSettings
if (!existingSettings) {
// Create new settings with updates
const [newSettings] = await db
.insert(schema.scannerSettings)
.values({ userId: user.id, ...dbUpdates })
.returning()
resultSettings = newSettings
} else {
// Update existing settings
const [updatedSettings] = await db
.update(schema.scannerSettings)
.set(dbUpdates)
.where(eq(schema.scannerSettings.userId, user.id))
.returning()
resultSettings = updatedSettings
}
// Transform back to QuadDetectorConfig format
const config = {
preprocessing: resultSettings.preprocessing,
enableHistogramEqualization: resultSettings.enableHistogramEqualization,
enableAdaptiveThreshold: resultSettings.enableAdaptiveThreshold,
enableMorphGradient: resultSettings.enableMorphGradient,
cannyThresholds: [resultSettings.cannyLow, resultSettings.cannyHigh] as [number, number],
adaptiveBlockSize: resultSettings.adaptiveBlockSize,
adaptiveC: resultSettings.adaptiveC,
enableHoughLines: resultSettings.enableHoughLines,
}
return NextResponse.json({ settings: config })
} catch (error) {
console.error('Failed to update scanner settings:', error)
return NextResponse.json({ error: 'Failed to update scanner settings' }, { status: 500 })
}
}
/**
* Get or create a user record for the given viewer ID (guest or user)
*/
async function getOrCreateUser(viewerId: string) {
// Try to find existing user by guest ID
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
// If no user exists, create one
if (!user) {
const [newUser] = await db
.insert(schema.users)
.values({
guestId: viewerId,
})
.returning()
user = newUser
}
return user
}

View File

@ -3,6 +3,9 @@
import dynamic from 'next/dynamic'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useDocumentDetection } from '@/components/practice/useDocumentDetection'
import { ScannerControlsDrawer } from '@/components/practice/ScannerControlsDrawer'
import { useScannerSettings, useUpdateScannerSettings } from '@/hooks/useScannerSettings'
import type { QuadDetectorConfig } from '@/lib/vision/quadDetector'
import { css } from '../../../../../styled-system/css'
// Dynamic import for DocumentAdjuster (pulls in OpenCV)
@ -11,6 +14,108 @@ const DocumentAdjuster = dynamic(
{ ssr: false }
)
// Lighting presets for different conditions
const LIGHTING_PRESETS = {
normal: {
label: 'Normal',
icon: '☀️',
config: {
preprocessing: 'multi' as const,
enableHistogramEqualization: true,
enableAdaptiveThreshold: true,
enableMorphGradient: true,
cannyThresholds: [50, 150] as [number, number],
adaptiveBlockSize: 11,
adaptiveC: 2,
},
},
lowLight: {
label: 'Low Light',
icon: '🌙',
config: {
preprocessing: 'multi' as const,
enableHistogramEqualization: true,
enableAdaptiveThreshold: true,
enableMorphGradient: true,
cannyThresholds: [30, 100] as [number, number],
adaptiveBlockSize: 15,
adaptiveC: 5,
},
},
bright: {
label: 'Bright',
icon: '🔆',
config: {
preprocessing: 'enhanced' as const,
enableHistogramEqualization: true,
enableAdaptiveThreshold: false,
enableMorphGradient: false,
cannyThresholds: [80, 200] as [number, number],
adaptiveBlockSize: 11,
adaptiveC: 2,
},
},
}
// Analyze video frame to determine best preset
function analyzeFrameLighting(video: HTMLVideoElement): {
preset: 'normal' | 'lowLight' | 'bright'
brightness: number
contrast: number
} {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) return { preset: 'normal', brightness: 128, contrast: 50 }
// Sample at lower resolution for speed
const sampleWidth = 160
const sampleHeight = 120
canvas.width = sampleWidth
canvas.height = sampleHeight
ctx.drawImage(video, 0, 0, sampleWidth, sampleHeight)
const imageData = ctx.getImageData(0, 0, sampleWidth, sampleHeight)
const data = imageData.data
// Calculate luminance for each pixel (simple average)
let totalLuminance = 0
const luminances: number[] = []
const pixelCount = data.length / 4
for (let i = 0; i < data.length; i += 4) {
// Weighted luminance: 0.299*R + 0.587*G + 0.114*B
const luminance = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]
luminances.push(luminance)
totalLuminance += luminance
}
const avgBrightness = totalLuminance / pixelCount
// Calculate contrast (standard deviation)
let varianceSum = 0
for (const lum of luminances) {
varianceSum += (lum - avgBrightness) ** 2
}
const contrast = Math.sqrt(varianceSum / pixelCount)
// Determine preset based on metrics
let preset: 'normal' | 'lowLight' | 'bright' = 'normal'
if (avgBrightness < 70) {
// Dark scene
preset = 'lowLight'
} else if (avgBrightness > 180 && contrast < 40) {
// Bright and washed out
preset = 'bright'
} else if (avgBrightness > 160) {
// Just bright
preset = 'bright'
}
// Otherwise normal
return { preset, brightness: avgBrightness, contrast }
}
interface FullscreenCameraProps {
/** Called with cropped file, original file, corners, and rotation for later re-editing */
onCapture: (
@ -28,7 +133,6 @@ export function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps)
const overlayCanvasRef = useRef<HTMLCanvasElement>(null)
const streamRef = useRef<MediaStream | null>(null)
const animationFrameRef = useRef<number | null>(null)
const lastDetectionRef = useRef<number>(0)
const autoCaptureTriggeredRef = useRef(false)
const [isReady, setIsReady] = useState(false)
@ -36,12 +140,33 @@ export function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps)
const [isCapturing, setIsCapturing] = useState(false)
const [documentDetected, setDocumentDetected] = useState(false)
// Camera controls state
const [torchAvailable, setTorchAvailable] = useState(false)
const [torchOn, setTorchOn] = useState(false)
const [hasMultipleCameras, setHasMultipleCameras] = useState(false)
const [facingMode, setFacingMode] = useState<'environment' | 'user'>('environment')
// Preset selector state
const [presetMode, setPresetMode] = useState<'auto' | 'normal' | 'lowLight' | 'bright'>('auto')
const [detectedPreset, setDetectedPreset] = useState<'normal' | 'lowLight' | 'bright'>('normal')
const [presetPopoverOpen, setPresetPopoverOpen] = useState(false)
const [fingerOcclusionMode, setFingerOcclusionMode] = useState(false)
// Adjustment mode state
const [adjustmentMode, setAdjustmentMode] = useState<{
sourceCanvas: HTMLCanvasElement
corners: Array<{ x: number; y: number }>
} | null>(null)
// Advanced controls drawer state
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const drawerSwipeRef = useRef<{ startX: number; startY: number } | null>(null)
const settingsInitializedRef = useRef(false)
// Load persisted scanner settings from database
const { data: savedSettings } = useScannerSettings()
const updateSettingsMutation = useUpdateScannerSettings()
// Document detection hook (lazy loads OpenCV.js)
const {
isLoading: isScannerLoading,
@ -55,8 +180,101 @@ export function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps)
captureSourceFrame,
highlightDocument,
detectQuadsInImage: detectQuadsInCamera,
resetTracking,
updateDetectorConfig,
detectorConfig,
} = useDocumentDetection()
// Initialize detector config with saved settings when they load
useEffect(() => {
if (savedSettings && !settingsInitializedRef.current) {
settingsInitializedRef.current = true
updateDetectorConfig(savedSettings)
}
}, [savedSettings, updateDetectorConfig])
// Handle config changes - update both local state and persist to database
const handleConfigChange = useCallback(
(newConfig: Partial<QuadDetectorConfig>) => {
// Update local detector immediately for instant feedback
updateDetectorConfig(newConfig)
// Persist to database (uses optimistic update)
updateSettingsMutation.mutate(newConfig)
},
[updateDetectorConfig, updateSettingsMutation]
)
// Apply preset config when preset mode or finger occlusion changes
const applyPresetConfig = useCallback(
(presetKey: 'normal' | 'lowLight' | 'bright') => {
const preset = LIGHTING_PRESETS[presetKey]
const config = { ...preset.config }
// Add finger occlusion enhancements if enabled
if (fingerOcclusionMode) {
config.enableHoughLines = true
config.enableMorphGradient = true
// Slightly lower thresholds to catch partial edges
config.cannyThresholds = [
Math.max(20, config.cannyThresholds[0] - 15),
Math.max(80, config.cannyThresholds[1] - 30),
] as [number, number]
}
updateDetectorConfig(config)
},
[fingerOcclusionMode, updateDetectorConfig]
)
// Analyze frame periodically when in auto mode
useEffect(() => {
if (presetMode !== 'auto' || !isReady || !videoRef.current || adjustmentMode) return
const analyzeInterval = setInterval(() => {
if (videoRef.current) {
const analysis = analyzeFrameLighting(videoRef.current)
if (analysis.preset !== detectedPreset) {
setDetectedPreset(analysis.preset)
applyPresetConfig(analysis.preset)
}
}
}, 2000) // Analyze every 2 seconds
// Initial analysis
const analysis = analyzeFrameLighting(videoRef.current)
setDetectedPreset(analysis.preset)
applyPresetConfig(analysis.preset)
return () => clearInterval(analyzeInterval)
}, [presetMode, isReady, adjustmentMode, detectedPreset, applyPresetConfig])
// Apply preset when manually selected
useEffect(() => {
if (presetMode !== 'auto') {
applyPresetConfig(presetMode)
}
}, [presetMode, applyPresetConfig])
// Reapply current preset when finger occlusion mode changes
useEffect(() => {
const currentPreset = presetMode === 'auto' ? detectedPreset : presetMode
applyPresetConfig(currentPreset)
}, [fingerOcclusionMode, presetMode, detectedPreset, applyPresetConfig])
// Check for multiple cameras on mount
useEffect(() => {
const checkCameras = async () => {
try {
const devices = await navigator.mediaDevices.enumerateDevices()
const videoDevices = devices.filter((d) => d.kind === 'videoinput')
setHasMultipleCameras(videoDevices.length > 1)
} catch {
// Ignore errors - just won't show flip button
}
}
checkCameras()
}, [])
useEffect(() => {
let cancelled = false
@ -68,7 +286,7 @@ export function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps)
const constraints: MediaStreamConstraints = {
video: {
facingMode: { ideal: 'environment' },
facingMode: { ideal: facingMode },
width: { ideal: 1920 },
height: { ideal: 1080 },
},
@ -84,6 +302,16 @@ export function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps)
streamRef.current = stream
// Check for torch capability
const videoTrack = stream.getVideoTracks()[0]
if (videoTrack) {
const capabilities = videoTrack.getCapabilities?.()
// @ts-expect-error - torch is not in the standard types but exists on mobile
const hasTorch = capabilities?.torch === true
setTorchAvailable(hasTorch)
setTorchOn(false) // Reset torch state when camera changes
}
if (videoRef.current) {
videoRef.current.srcObject = stream
await videoRef.current.play()
@ -110,11 +338,12 @@ export function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps)
streamRef.current = null
}
}
}, [ensureOpenCVLoaded])
}, [ensureOpenCVLoaded, facingMode])
// Detection loop - runs when camera and scanner are ready
// Detection loop - runs when camera and scanner are ready, and NOT in adjustment mode
useEffect(() => {
if (!isReady || !isScannerReady) return
// Don't run detection loop while in adjustment mode
if (!isReady || !isScannerReady || adjustmentMode) return
const video = videoRef.current
const overlay = overlayCanvasRef.current
@ -130,14 +359,11 @@ export function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps)
syncCanvasSize()
const detectLoop = () => {
const now = Date.now()
// Throttle detection to every 150ms for performance
if (now - lastDetectionRef.current > 150) {
if (video && overlay) {
const detected = highlightDocument(video, overlay)
setDocumentDetected(detected)
}
lastDetectionRef.current = now
// Run detection every frame for smooth tracking
// Detection typically takes ~8-10ms, well within 60fps budget
if (video && overlay) {
const detected = highlightDocument(video, overlay)
setDocumentDetected(detected)
}
animationFrameRef.current = requestAnimationFrame(detectLoop)
}
@ -155,7 +381,7 @@ export function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps)
}
window.removeEventListener('resize', syncCanvasSize)
}
}, [isReady, isScannerReady, highlightDocument])
}, [isReady, isScannerReady, highlightDocument, adjustmentMode])
// Enter adjustment mode with captured frame and detected corners
// Always shows the adjustment UI - uses fallback corners if no quad detected
@ -248,49 +474,84 @@ export function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps)
)
// Handle adjustment cancel - return to camera
// The detection loop will automatically restart via useEffect when adjustmentMode becomes null
const handleAdjustmentCancel = useCallback(() => {
setAdjustmentMode(null)
autoCaptureTriggeredRef.current = false // Allow auto-capture again
// Restart detection loop
if (videoRef.current && overlayCanvasRef.current && isScannerReady) {
const detectLoop = () => {
const now = Date.now()
if (now - lastDetectionRef.current > 150) {
if (videoRef.current && overlayCanvasRef.current) {
const detected = highlightDocument(videoRef.current, overlayCanvasRef.current)
setDocumentDetected(detected)
}
lastDetectionRef.current = now
}
animationFrameRef.current = requestAnimationFrame(detectLoop)
}
animationFrameRef.current = requestAnimationFrame(detectLoop)
}
}, [isScannerReady, highlightDocument])
resetTracking() // Clear old quad detection state
}, [resetTracking])
// Show adjustment UI if in adjustment mode
if (adjustmentMode && opencvRef) {
return (
<DocumentAdjuster
sourceCanvas={adjustmentMode.sourceCanvas}
initialCorners={adjustmentMode.corners}
onConfirm={handleAdjustmentConfirm}
onCancel={handleAdjustmentCancel}
cv={opencvRef}
detectQuadsInImage={detectQuadsInCamera}
/>
)
}
// Show adjustment UI if in adjustment mode (overlay on top of camera)
// Keep camera mounted but hidden to preserve video stream
const showAdjuster = !!(adjustmentMode && opencvRef)
// Toggle torch/flashlight
const toggleTorch = useCallback(async () => {
if (!streamRef.current || !torchAvailable) return
const videoTrack = streamRef.current.getVideoTracks()[0]
if (!videoTrack) return
try {
const newTorchState = !torchOn
await videoTrack.applyConstraints({
// @ts-expect-error - advanced torch constraint exists on mobile
advanced: [{ torch: newTorchState }],
})
setTorchOn(newTorchState)
} catch (err) {
console.error('Failed to toggle torch:', err)
}
}, [torchAvailable, torchOn])
// Flip between front and back camera
const flipCamera = useCallback(() => {
// Reset tracking and torch state before switching
resetTracking()
setTorchOn(false)
setIsReady(false)
setFacingMode((prev) => (prev === 'environment' ? 'user' : 'environment'))
}, [resetTracking])
// Swipe handlers for drawer
const handleTouchStart = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0]
// Only track swipes starting from left edge (first 30px)
if (touch.clientX < 30) {
drawerSwipeRef.current = { startX: touch.clientX, startY: touch.clientY }
}
}, [])
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (!drawerSwipeRef.current) return
const touch = e.touches[0]
const deltaX = touch.clientX - drawerSwipeRef.current.startX
const deltaY = Math.abs(touch.clientY - drawerSwipeRef.current.startY)
// If swiping right and more horizontal than vertical, open drawer
if (deltaX > 50 && deltaX > deltaY * 2) {
setIsDrawerOpen(true)
drawerSwipeRef.current = null
}
}, [])
const handleTouchEnd = useCallback(() => {
drawerSwipeRef.current = null
}, [])
return (
<div
data-component="fullscreen-camera"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
className={css({
position: 'absolute',
inset: 0,
bg: 'black',
})}
>
{/* Always render video to keep stream alive */}
<video
ref={videoRef}
playsInline
@ -301,24 +562,43 @@ export function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps)
width: '100%',
height: '100%',
objectFit: 'cover',
// Hide when in adjustment mode but keep mounted
display: showAdjuster ? 'none' : 'block',
})}
/>
{/* Overlay canvas for document detection visualization */}
<canvas
ref={overlayCanvasRef}
className={css({
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
})}
/>
{/* Document adjuster overlay */}
{showAdjuster && (
<DocumentAdjuster
sourceCanvas={adjustmentMode.sourceCanvas}
initialCorners={adjustmentMode.corners}
onConfirm={handleAdjustmentConfirm}
onCancel={handleAdjustmentCancel}
cv={opencvRef}
detectQuadsInImage={detectQuadsInCamera}
/>
)}
<canvas ref={canvasRef} style={{ display: 'none' }} />
{/* Camera UI - hidden when adjuster is shown */}
{!showAdjuster && (
<>
{/* Overlay canvas for document detection visualization */}
<canvas
ref={overlayCanvasRef}
className={css({
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
})}
/>
{!isReady && !error && (
<canvas ref={canvasRef} style={{ display: 'none' }} />
</>
)}
{!showAdjuster && !isReady && !error && (
<div
className={css({
position: 'absolute',
@ -333,7 +613,7 @@ export function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps)
</div>
)}
{error && (
{!showAdjuster && error && (
<div
className={css({
position: 'absolute',
@ -375,32 +655,259 @@ export function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps)
</div>
)}
{!error && (
{!showAdjuster && !error && (
<>
<button
type="button"
onClick={onClose}
{/* Top right controls */}
<div
data-element="top-controls"
className={css({
position: 'absolute',
top: 4,
right: 4,
width: '48px',
height: '48px',
bg: 'rgba(0, 0, 0, 0.5)',
color: 'white',
borderRadius: 'full',
fontSize: '2xl',
fontWeight: 'bold',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backdropFilter: 'blur(4px)',
_hover: { bg: 'rgba(0, 0, 0, 0.7)' },
gap: 2,
zIndex: 10,
})}
>
×
</button>
{/* Preset selector with popover */}
<div className={css({ position: 'relative' })}>
<button
type="button"
onClick={() => setPresetPopoverOpen(!presetPopoverOpen)}
className={css({
height: '44px',
px: 3,
bg: 'rgba(0, 0, 0, 0.5)',
color: 'white',
borderRadius: 'full',
fontSize: 'sm',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 1.5,
backdropFilter: 'blur(4px)',
_hover: { bg: 'rgba(0, 0, 0, 0.7)' },
})}
title="Lighting preset"
>
<span>
{presetMode === 'auto'
? `🅰️ ${LIGHTING_PRESETS[detectedPreset].icon}`
: LIGHTING_PRESETS[presetMode].icon}
</span>
<span className={css({ fontSize: 'xs' })}>
{presetMode === 'auto' ? 'Auto' : LIGHTING_PRESETS[presetMode].label}
</span>
</button>
{/* Popover */}
{presetPopoverOpen && (
<>
{/* Backdrop to close popover */}
<div
onClick={() => setPresetPopoverOpen(false)}
className={css({
position: 'fixed',
inset: 0,
zIndex: 5,
})}
/>
<div
data-element="preset-popover"
className={css({
position: 'absolute',
top: '100%',
right: 0,
mt: 2,
bg: 'rgba(0, 0, 0, 0.9)',
backdropFilter: 'blur(8px)',
borderRadius: 'lg',
border: '1px solid',
borderColor: 'rgba(255, 255, 255, 0.2)',
overflow: 'hidden',
zIndex: 10,
minWidth: '140px',
})}
>
{/* Auto option */}
<button
type="button"
onClick={() => {
setPresetMode('auto')
setPresetPopoverOpen(false)
}}
className={css({
width: '100%',
px: 3,
py: 2,
display: 'flex',
alignItems: 'center',
gap: 2,
bg: presetMode === 'auto' ? 'rgba(59, 130, 246, 0.3)' : 'transparent',
color: 'white',
fontSize: 'sm',
cursor: 'pointer',
_hover: { bg: 'rgba(255, 255, 255, 0.1)' },
})}
>
<span>🅰</span>
<span>Auto</span>
{presetMode === 'auto' && (
<span className={css({ ml: 'auto', fontSize: 'xs', color: 'blue.400' })}>
</span>
)}
</button>
{/* Divider */}
<div className={css({ h: '1px', bg: 'rgba(255, 255, 255, 0.1)' })} />
{/* Preset options */}
{(Object.keys(LIGHTING_PRESETS) as Array<keyof typeof LIGHTING_PRESETS>).map(
(key) => (
<button
type="button"
key={key}
onClick={() => {
setPresetMode(key)
setPresetPopoverOpen(false)
}}
className={css({
width: '100%',
px: 3,
py: 2,
display: 'flex',
alignItems: 'center',
gap: 2,
bg: presetMode === key ? 'rgba(59, 130, 246, 0.3)' : 'transparent',
color: 'white',
fontSize: 'sm',
cursor: 'pointer',
_hover: { bg: 'rgba(255, 255, 255, 0.1)' },
})}
>
<span>{LIGHTING_PRESETS[key].icon}</span>
<span>{LIGHTING_PRESETS[key].label}</span>
{presetMode === key && (
<span
className={css({ ml: 'auto', fontSize: 'xs', color: 'blue.400' })}
>
</span>
)}
{presetMode === 'auto' && detectedPreset === key && (
<span
className={css({ ml: 'auto', fontSize: '9px', color: 'gray.400' })}
>
detected
</span>
)}
</button>
)
)}
</div>
</>
)}
</div>
{/* Finger occlusion mode toggle */}
<button
type="button"
onClick={() => setFingerOcclusionMode(!fingerOcclusionMode)}
className={css({
width: '44px',
height: '44px',
bg: fingerOcclusionMode ? 'purple.600' : 'rgba(0, 0, 0, 0.5)',
color: 'white',
borderRadius: 'full',
fontSize: 'lg',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backdropFilter: 'blur(4px)',
transition: 'all 0.2s',
_hover: { bg: fingerOcclusionMode ? 'purple.500' : 'rgba(0, 0, 0, 0.7)' },
})}
title={fingerOcclusionMode ? 'Finger mode ON' : 'Finger mode OFF (experimental)'}
>
👆
</button>
{/* Torch toggle - only shown when available */}
{torchAvailable && (
<button
type="button"
onClick={toggleTorch}
className={css({
width: '44px',
height: '44px',
bg: torchOn ? 'yellow.400' : 'rgba(0, 0, 0, 0.5)',
color: torchOn ? 'black' : 'white',
borderRadius: 'full',
fontSize: 'xl',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backdropFilter: 'blur(4px)',
transition: 'all 0.2s',
_hover: { bg: torchOn ? 'yellow.300' : 'rgba(0, 0, 0, 0.7)' },
})}
title={torchOn ? 'Turn off flashlight' : 'Turn on flashlight'}
>
{torchOn ? '🔦' : '💡'}
</button>
)}
{/* Camera flip - only shown when multiple cameras */}
{hasMultipleCameras && (
<button
type="button"
onClick={flipCamera}
className={css({
width: '44px',
height: '44px',
bg: 'rgba(0, 0, 0, 0.5)',
color: 'white',
borderRadius: 'full',
fontSize: 'xl',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backdropFilter: 'blur(4px)',
_hover: { bg: 'rgba(0, 0, 0, 0.7)' },
})}
title="Flip camera"
>
🔄
</button>
)}
{/* Close button */}
<button
type="button"
onClick={onClose}
className={css({
width: '44px',
height: '44px',
bg: 'rgba(0, 0, 0, 0.5)',
color: 'white',
borderRadius: 'full',
fontSize: '2xl',
fontWeight: 'bold',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backdropFilter: 'blur(4px)',
_hover: { bg: 'rgba(0, 0, 0, 0.7)' },
})}
>
×
</button>
</div>
{/* Debug overlay panel - always shown to help diagnose detection */}
<div
@ -599,6 +1106,15 @@ export function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps)
)}
</button>
</div>
{/* Advanced controls drawer - swipe from left edge or click hint to open */}
<ScannerControlsDrawer
isOpen={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
onOpen={() => setIsDrawerOpen(true)}
config={detectorConfig}
onConfigChange={handleConfigChange}
/>
</>
)}
</div>

View File

@ -0,0 +1,644 @@
'use client'
import { useCallback } from 'react'
import { css } from '../../../styled-system/css'
import type { QuadDetectorConfig, PreprocessingStrategy } from '@/lib/vision/quadDetector'
// Presets for common scanning scenarios
const PRESETS: Record<string, { config: Partial<QuadDetectorConfig>; label: string }> = {
normal: {
label: 'Normal',
config: {
preprocessing: 'multi',
enableHistogramEqualization: true,
enableAdaptiveThreshold: true,
enableMorphGradient: true,
cannyThresholds: [50, 150],
adaptiveBlockSize: 11,
adaptiveC: 2,
enableHoughLines: true,
},
},
lowLight: {
label: 'Low Light',
config: {
preprocessing: 'multi',
enableHistogramEqualization: true,
enableAdaptiveThreshold: true,
enableMorphGradient: true,
cannyThresholds: [30, 100],
adaptiveBlockSize: 15,
adaptiveC: 5,
enableHoughLines: true,
},
},
fingers: {
label: 'Fingers',
config: {
preprocessing: 'multi',
enableHistogramEqualization: true,
enableAdaptiveThreshold: true,
enableMorphGradient: true,
cannyThresholds: [40, 120],
adaptiveBlockSize: 11,
adaptiveC: 2,
enableHoughLines: true,
},
},
bright: {
label: 'Bright',
config: {
preprocessing: 'enhanced',
enableHistogramEqualization: true,
enableAdaptiveThreshold: false,
enableMorphGradient: false,
cannyThresholds: [80, 200],
adaptiveBlockSize: 11,
adaptiveC: 2,
enableHoughLines: false,
},
},
}
interface ScannerControlsDrawerProps {
isOpen: boolean
onClose: () => void
onOpen: () => void
config: Partial<QuadDetectorConfig>
onConfigChange: (config: Partial<QuadDetectorConfig>) => void
}
export function ScannerControlsDrawer({
isOpen,
onClose,
onOpen,
config,
onConfigChange,
}: ScannerControlsDrawerProps) {
const handlePreset = useCallback(
(presetName: keyof typeof PRESETS) => {
onConfigChange(PRESETS[presetName].config)
},
[onConfigChange]
)
const updateField = useCallback(
<K extends keyof QuadDetectorConfig>(field: K, value: QuadDetectorConfig[K]) => {
onConfigChange({ [field]: value })
},
[onConfigChange]
)
// Get current values with defaults
const preprocessing = (config.preprocessing ?? 'multi') as PreprocessingStrategy
const enableHistogramEqualization = config.enableHistogramEqualization ?? true
const enableAdaptiveThreshold = config.enableAdaptiveThreshold ?? true
const enableMorphGradient = config.enableMorphGradient ?? true
const enableHoughLines = config.enableHoughLines ?? true
const cannyThresholds = config.cannyThresholds ?? [50, 150]
const adaptiveBlockSize = config.adaptiveBlockSize ?? 11
const adaptiveC = config.adaptiveC ?? 2
return (
<>
{/* Backdrop - click to close */}
<div
data-element="drawer-backdrop"
onClick={onClose}
className={css({
position: 'absolute',
inset: 0,
bg: 'rgba(0, 0, 0, 0.3)',
opacity: isOpen ? 1 : 0,
pointerEvents: isOpen ? 'auto' : 'none',
transition: 'opacity 0.3s ease',
zIndex: 50,
})}
/>
{/* Drawer - responsive width: narrow on small screens, wider on tablets+ */}
<div
data-component="scanner-controls-drawer"
className={css({
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: 'min(180px, 55vw)',
bg: 'rgba(0, 0, 0, 0.75)',
backdropFilter: 'blur(16px)',
transform: isOpen ? 'translateX(0)' : 'translateX(-100%)',
transition: 'transform 0.3s ease',
zIndex: 51,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
borderRight: '1px solid',
borderColor: 'rgba(255, 255, 255, 0.1)',
// Wider on tablets and up
'@media (min-width: 480px)': {
width: 'min(240px, 60vw)',
},
})}
>
{/* Header - compact */}
<div
className={css({
px: 2,
py: 1.5,
borderBottom: '1px solid',
borderColor: 'rgba(255, 255, 255, 0.1)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0,
'@media (min-width: 480px)': {
px: 3,
py: 2,
},
})}
>
<span
className={css({
color: 'white',
fontWeight: 'bold',
fontSize: 'xs',
textTransform: 'uppercase',
letterSpacing: '0.05em',
})}
>
Scanner
</span>
<button
type="button"
onClick={onClose}
className={css({
color: 'gray.400',
fontSize: 'lg',
lineHeight: 1,
cursor: 'pointer',
p: 1,
_hover: { color: 'white' },
})}
>
×
</button>
</div>
{/* Scrollable content - responsive spacing */}
<div
className={css({
flex: 1,
overflowY: 'auto',
px: 2,
py: 1.5,
display: 'flex',
flexDirection: 'column',
gap: 2,
'@media (min-width: 480px)': {
px: 3,
py: 2,
gap: 3,
},
})}
>
{/* Presets - 2x2 grid */}
<Section title="Presets">
<div
className={css({
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 1,
'@media (min-width: 480px)': { gap: 1.5 },
})}
>
{Object.entries(PRESETS).map(([key, preset]) => (
<PresetButton
key={key}
label={preset.label}
onClick={() => handlePreset(key as keyof typeof PRESETS)}
/>
))}
</div>
</Section>
{/* Strategy dropdown */}
<Section title="Strategy">
<select
value={preprocessing}
onChange={(e) =>
updateField('preprocessing', e.target.value as PreprocessingStrategy)
}
className={css({
width: '100%',
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
border: '1px solid',
borderColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: 'sm',
px: 2,
py: 1.5,
fontSize: 'xs',
cursor: 'pointer',
_focus: { outline: 'none', borderColor: 'blue.400' },
})}
>
<option value="standard">Standard</option>
<option value="enhanced">Enhanced</option>
<option value="adaptive">Adaptive</option>
<option value="multi">Multi (best)</option>
</select>
</Section>
{/* Preprocessing toggles - compact */}
<Section title="Processing">
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: 1.5,
'@media (min-width: 480px)': { gap: 2 },
})}
>
<Toggle
label="Histogram EQ"
sublabel="Stretches contrast for low-light/washed-out images"
checked={enableHistogramEqualization}
onChange={(v) => updateField('enableHistogramEqualization', v)}
/>
<Toggle
label="Adaptive Threshold"
sublabel="Local thresholding for uneven lighting & shadows"
checked={enableAdaptiveThreshold}
onChange={(v) => updateField('enableAdaptiveThreshold', v)}
/>
<Toggle
label="Morph Gradient"
sublabel="Dilation minus erosion to find thick edges"
checked={enableMorphGradient}
onChange={(v) => updateField('enableMorphGradient', v)}
/>
<Toggle
label="Hough Lines"
sublabel="Finds lines even when edges are broken by fingers"
checked={enableHoughLines}
onChange={(v) => updateField('enableHoughLines', v)}
/>
</div>
</Section>
{/* Edge detection sliders */}
<Section title="Canny Edge Detection">
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: 2,
'@media (min-width: 480px)': { gap: 3 },
})}
>
<Slider
label="Low Threshold"
sublabel="Min gradient to start edge. Lower = more sensitive"
value={cannyThresholds[0]}
min={10}
max={150}
step={5}
onChange={(v) => updateField('cannyThresholds', [v, cannyThresholds[1]])}
/>
<Slider
label="High Threshold"
sublabel="Min gradient to confirm edge. Higher = stronger edges only"
value={cannyThresholds[1]}
min={50}
max={300}
step={10}
onChange={(v) => updateField('cannyThresholds', [cannyThresholds[0], v])}
/>
</div>
</Section>
{/* Adaptive settings */}
<Section title="Adaptive Threshold">
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: 2,
'@media (min-width: 480px)': { gap: 3 },
})}
>
<Slider
label="Block Size"
sublabel="Neighborhood size. Larger = smoother, may miss details"
value={adaptiveBlockSize}
min={3}
max={31}
step={2}
onChange={(v) => updateField('adaptiveBlockSize', v)}
/>
<Slider
label="Constant C"
sublabel="Subtracted from mean. Higher = darker pixels needed"
value={adaptiveC}
min={-10}
max={20}
step={1}
onChange={(v) => updateField('adaptiveC', v)}
/>
</div>
</Section>
</div>
</div>
{/* Edge hint when closed */}
{!isOpen && (
<div
data-element="drawer-hint"
onClick={onOpen}
className={css({
position: 'absolute',
top: '50%',
left: 0,
transform: 'translateY(-50%)',
width: '4px',
height: '48px',
bg: 'rgba(255, 255, 255, 0.25)',
borderRadius: '0 4px 4px 0',
cursor: 'pointer',
transition: 'all 0.2s',
zIndex: 20,
_hover: {
width: '6px',
height: '64px',
bg: 'rgba(255, 255, 255, 0.4)',
},
})}
/>
)}
</>
)
}
// Helper components - ultra compact
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<div
className={css({
color: 'gray.500',
fontSize: '9px',
fontWeight: 'medium',
textTransform: 'uppercase',
letterSpacing: '0.05em',
mb: 1,
'@media (min-width: 480px)': {
fontSize: '10px',
mb: 1.5,
},
})}
>
{title}
</div>
{children}
</div>
)
}
function PresetButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className={css({
px: 1.5,
py: 1,
fontSize: '10px',
fontWeight: 'medium',
borderRadius: 'sm',
border: '1px solid',
cursor: 'pointer',
transition: 'all 0.15s',
bg: 'rgba(255, 255, 255, 0.08)',
borderColor: 'rgba(255, 255, 255, 0.15)',
color: 'gray.300',
'@media (min-width: 480px)': {
px: 2,
py: 1.5,
fontSize: '11px',
},
_hover: {
bg: 'rgba(255, 255, 255, 0.12)',
borderColor: 'rgba(255, 255, 255, 0.25)',
},
_active: {
bg: 'rgba(255, 255, 255, 0.15)',
},
})}
>
{label}
</button>
)
}
function Toggle({
label,
sublabel,
checked,
onChange,
}: {
label: string
sublabel?: string
checked: boolean
onChange: (checked: boolean) => void
}) {
return (
<div
onClick={() => onChange(!checked)}
className={css({
display: 'flex',
alignItems: 'center',
gap: 1.5,
cursor: 'pointer',
'@media (min-width: 480px)': {
gap: 2,
},
})}
>
<div
className={css({
position: 'relative',
width: '28px',
height: '16px',
flexShrink: 0,
bg: checked ? 'green.600' : 'rgba(255, 255, 255, 0.15)',
borderRadius: 'full',
transition: 'background-color 0.2s',
'@media (min-width: 480px)': {
width: '32px',
height: '18px',
},
})}
>
<div
className={css({
position: 'absolute',
top: '2px',
left: checked ? '14px' : '2px',
width: '12px',
height: '12px',
bg: 'white',
borderRadius: 'full',
transition: 'left 0.2s',
boxShadow: '0 1px 2px rgba(0,0,0,0.3)',
'@media (min-width: 480px)': {
left: checked ? '16px' : '2px',
width: '14px',
height: '14px',
},
})}
/>
</div>
<div className={css({ flex: 1, minWidth: 0 })}>
<div
className={css({
color: 'white',
fontSize: '10px',
'@media (min-width: 480px)': {
fontSize: '11px',
},
})}
>
{label}
</div>
{sublabel && (
<div
className={css({
color: 'gray.500',
fontSize: '9px',
lineHeight: '1.3',
mt: '1px',
// Hide on very small screens, show on tablets+
display: 'none',
'@media (min-width: 480px)': {
display: 'block',
fontSize: '10px',
},
})}
>
{sublabel}
</div>
)}
</div>
</div>
)
}
function Slider({
label,
sublabel,
value,
min,
max,
step,
onChange,
}: {
label: string
sublabel?: string
value: number
min: number
max: number
step: number
onChange: (value: number) => void
}) {
return (
<div>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 0.5,
'@media (min-width: 480px)': {
mb: sublabel ? 0.5 : 1,
},
})}
>
<span
className={css({
color: 'gray.300',
fontSize: '10px',
'@media (min-width: 480px)': { fontSize: '11px' },
})}
>
{label}
</span>
<span
className={css({
color: 'white',
fontSize: '10px',
fontFamily: 'mono',
'@media (min-width: 480px)': { fontSize: '11px' },
})}
>
{value}
</span>
</div>
{sublabel && (
<div
className={css({
color: 'gray.500',
fontSize: '9px',
mb: 1,
lineHeight: '1.3',
// Hide on small screens
display: 'none',
'@media (min-width: 480px)': {
display: 'block',
fontSize: '10px',
},
})}
>
{sublabel}
</div>
)}
<input
type="range"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => onChange(Number(e.target.value))}
className={css({
width: '100%',
height: '4px',
bg: 'rgba(255, 255, 255, 0.15)',
borderRadius: 'full',
appearance: 'none',
cursor: 'pointer',
'&::-webkit-slider-thumb': {
appearance: 'none',
width: '14px',
height: '14px',
bg: 'white',
borderRadius: 'full',
cursor: 'pointer',
boxShadow: '0 1px 2px rgba(0,0,0,0.3)',
},
'&::-moz-range-thumb': {
width: '14px',
height: '14px',
bg: 'white',
borderRadius: 'full',
cursor: 'pointer',
border: 'none',
boxShadow: '0 1px 2px rgba(0,0,0,0.3)',
},
})}
/>
</div>
)
}
export default ScannerControlsDrawer

View File

@ -32,6 +32,7 @@ export * from './room-join-requests'
export * from './room-member-history'
export * from './room-members'
export * from './room-reports'
export * from './scanner-settings'
export * from './user-stats'
export * from './users'
export * from './worksheet-attempts'

View File

@ -0,0 +1,55 @@
import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { users } from './users'
/**
* Scanner settings table - document scanner configuration per user
*
* One-to-one with users table. Stores quad detector configuration.
* Deleted when user is deleted (cascade).
*/
export const scannerSettings = sqliteTable('scanner_settings', {
/** Primary key and foreign key to users table */
userId: text('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
/** Preprocessing strategy */
preprocessing: text('preprocessing', {
enum: ['standard', 'enhanced', 'adaptive', 'multi'],
})
.notNull()
.default('multi'),
/** Enable histogram equalization - improves contrast in low light */
enableHistogramEqualization: integer('enable_histogram_equalization', { mode: 'boolean' })
.notNull()
.default(true),
/** Enable adaptive threshold - better for uneven lighting */
enableAdaptiveThreshold: integer('enable_adaptive_threshold', { mode: 'boolean' })
.notNull()
.default(true),
/** Enable morphological gradient - enhances document edges */
enableMorphGradient: integer('enable_morph_gradient', { mode: 'boolean' })
.notNull()
.default(true),
/** Canny edge detection low threshold */
cannyLow: integer('canny_low').notNull().default(50),
/** Canny edge detection high threshold */
cannyHigh: integer('canny_high').notNull().default(150),
/** Adaptive threshold block size (must be odd) */
adaptiveBlockSize: integer('adaptive_block_size').notNull().default(11),
/** Adaptive threshold constant C */
adaptiveC: real('adaptive_c').notNull().default(2),
/** Enable Hough line detection - helps with finger occlusion */
enableHoughLines: integer('enable_hough_lines', { mode: 'boolean' }).notNull().default(true),
})
export type ScannerSettings = typeof scannerSettings.$inferSelect
export type NewScannerSettings = typeof scannerSettings.$inferInsert

View File

@ -0,0 +1,104 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/queryClient'
import type { QuadDetectorConfig } from '@/lib/vision/quadDetector'
/**
* Scanner settings as returned by API (QuadDetectorConfig format)
*/
export type ScannerConfig = Pick<
QuadDetectorConfig,
| 'preprocessing'
| 'enableHistogramEqualization'
| 'enableAdaptiveThreshold'
| 'enableMorphGradient'
| 'cannyThresholds'
| 'adaptiveBlockSize'
| 'adaptiveC'
| 'enableHoughLines'
>
/**
* Query key factory for scanner settings
*/
export const scannerSettingsKeys = {
all: ['scanner-settings'] as const,
detail: () => [...scannerSettingsKeys.all, 'detail'] as const,
}
/**
* Fetch scanner settings
*/
async function fetchScannerSettings(): Promise<ScannerConfig> {
const res = await api('scanner-settings')
if (!res.ok) throw new Error('Failed to fetch scanner settings')
const data = await res.json()
return data.settings
}
/**
* Update scanner settings
*/
async function updateScannerSettings(updates: Partial<ScannerConfig>): Promise<ScannerConfig> {
const res = await api('scanner-settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
if (!res.ok) throw new Error('Failed to update scanner settings')
const data = await res.json()
return data.settings
}
/**
* Hook: Fetch scanner settings
*/
export function useScannerSettings() {
return useQuery({
queryKey: scannerSettingsKeys.detail(),
queryFn: fetchScannerSettings,
})
}
/**
* Hook: Update scanner settings
*
* Uses optimistic updates for instant UI feedback
*/
export function useUpdateScannerSettings() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateScannerSettings,
onMutate: async (updates) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({
queryKey: scannerSettingsKeys.detail(),
})
// Snapshot previous value
const previousSettings = queryClient.getQueryData<ScannerConfig>(scannerSettingsKeys.detail())
// Optimistically update
if (previousSettings) {
const optimisticSettings = { ...previousSettings, ...updates }
queryClient.setQueryData<ScannerConfig>(scannerSettingsKeys.detail(), optimisticSettings)
}
return { previousSettings }
},
onError: (_err, _updates, context) => {
// Rollback on error
if (context?.previousSettings) {
queryClient.setQueryData(scannerSettingsKeys.detail(), context.previousSettings)
}
},
onSettled: (updatedSettings) => {
// Update with server data on success
if (updatedSettings) {
queryClient.setQueryData(scannerSettingsKeys.detail(), updatedSettings)
}
},
})
}