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:
parent
fb0abcef27
commit
bc02ba281d
|
|
@ -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
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue