feat(practice): add document adjustment UI and auto-capture

Adds an interstitial adjustment UI between camera capture and session
report, allowing users to fine-tune document cropping and rotation.

New features:
- DocumentAdjuster component with draggable corner handles
- Live preview of cropped/rotated result
- 90° rotation controls (clockwise/counter-clockwise)
- Auto-capture: automatically enters adjustment mode when detection
  is locked (stable for 5+ frames with >50% stability)

New hook exports:
- cv: OpenCV reference for external use
- getBestQuadCorners(): get current detected quad corners
- captureSourceFrame(): capture video frame as canvas

Flow: Camera → Auto-capture on lock → Adjust corners/rotation → Confirm → Upload

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-12-31 12:09:35 -06:00
parent ff79a28c65
commit 473b7dbd7c
3 changed files with 684 additions and 5 deletions

View File

@@ -19,6 +19,7 @@ import {
StartPracticeModal,
} from '@/components/practice'
import { calculateAutoPauseInfo } from '@/components/practice/autoPauseCalculator'
import { DocumentAdjuster } from '@/components/practice/DocumentAdjuster'
import { useDocumentDetection } from '@/components/practice/useDocumentDetection'
import {
filterProblemsNeedingAttention,
@@ -586,12 +587,19 @@ function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps) {
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)
const [error, setError] = useState<string | null>(null)
const [isCapturing, setIsCapturing] = useState(false)
const [documentDetected, setDocumentDetected] = useState(false)
// Adjustment mode state
const [adjustmentMode, setAdjustmentMode] = useState<{
sourceCanvas: HTMLCanvasElement
corners: Array<{ x: number; y: number }>
} | null>(null)
// Document detection hook (lazy loads OpenCV.js)
const {
isLoading: isScannerLoading,
@@ -599,6 +607,9 @@ function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps) {
isStable: isDetectionStable,
isLocked: isDetectionLocked,
debugInfo: scannerDebugInfo,
cv: opencvRef,
getBestQuadCorners,
captureSourceFrame,
highlightDocument,
extractDocument,
} = useDocumentDetection()
@@ -696,23 +707,43 @@ function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps) {
}
}, [isReady, isScannerReady, highlightDocument])
const capturePhoto = async () => {
// Enter adjustment mode with captured frame and detected corners
const enterAdjustmentMode = useCallback(() => {
if (!videoRef.current) return
const video = videoRef.current
const sourceCanvas = captureSourceFrame(video)
const corners = getBestQuadCorners()
if (sourceCanvas && corners) {
// Stop detection loop while adjusting
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
setAdjustmentMode({ sourceCanvas, corners })
} else {
// No document detected, do quick capture without adjustment
captureWithoutAdjustment()
}
}, [captureSourceFrame, getBestQuadCorners])
// Quick capture without adjustment (fallback)
const captureWithoutAdjustment = async () => {
if (!videoRef.current || !canvasRef.current) return
setIsCapturing(true)
try {
const video = videoRef.current
const canvas = canvasRef.current
let sourceCanvas: HTMLCanvasElement
// Try to extract document if scanner is ready
let sourceCanvas: HTMLCanvasElement
if (isScannerReady) {
const extractedCanvas = extractDocument(video)
if (extractedCanvas) {
// Document successfully extracted and cropped
sourceCanvas = extractedCanvas
} else {
// Extraction failed, fall back to regular capture
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')
@@ -721,7 +752,6 @@ function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps) {
sourceCanvas = canvas
}
} else {
// Scanner not ready, use regular capture
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')
@@ -754,6 +784,80 @@ function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps) {
}
}
// Handle capture button - enters adjustment mode if document detected
const capturePhoto = () => {
if (isCapturing) return
setIsCapturing(true)
enterAdjustmentMode()
setIsCapturing(false)
}
// Auto-capture when detection is locked and stable
useEffect(() => {
if (
isDetectionLocked &&
isReady &&
isScannerReady &&
!isCapturing &&
!adjustmentMode &&
!autoCaptureTriggeredRef.current
) {
// Add a small delay to ensure stability
const timeout = setTimeout(() => {
if (isDetectionLocked && !autoCaptureTriggeredRef.current) {
autoCaptureTriggeredRef.current = true
console.log('Auto-capturing document...')
enterAdjustmentMode()
}
}, 500) // 500ms delay after lock to ensure stability
return () => clearTimeout(timeout)
}
}, [isDetectionLocked, isReady, isScannerReady, isCapturing, adjustmentMode, enterAdjustmentMode])
// Handle adjustment confirm
const handleAdjustmentConfirm = useCallback(
(file: File) => {
setAdjustmentMode(null)
onCapture(file)
},
[onCapture]
)
// Handle adjustment cancel - return to camera
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])
// Show adjustment UI if in adjustment mode
if (adjustmentMode && opencvRef) {
return (
<DocumentAdjuster
sourceCanvas={adjustmentMode.sourceCanvas}
initialCorners={adjustmentMode.corners}
onConfirm={handleAdjustmentConfirm}
onCancel={handleAdjustmentCancel}
cv={opencvRef}
/>
)
}
return (
<div
data-component="fullscreen-camera"

View File

@@ -0,0 +1,537 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { css } from '../../../styled-system/css'
interface Corner {
x: number
y: number
}
interface DocumentAdjusterProps {
/** The original captured image as a canvas */
sourceCanvas: HTMLCanvasElement
/** Initial corner positions (in source image coordinates) */
initialCorners: Corner[]
/** Callback when user confirms with final File */
onConfirm: (file: File) => void
/** Callback when user cancels */
onCancel: () => void
/** OpenCV reference for transformations */
cv: unknown
}
/**
* DocumentAdjuster - Interstitial UI for adjusting document crop and rotation
*
* Displays the original image with draggable corner handles and a live preview
* of the cropped/transformed result. Allows rotation in 90° increments.
*/
export function DocumentAdjuster({
sourceCanvas,
initialCorners,
onConfirm,
onCancel,
cv,
}: DocumentAdjusterProps) {
const containerRef = useRef<HTMLDivElement>(null)
const previewCanvasRef = useRef<HTMLCanvasElement>(null)
const [corners, setCorners] = useState<Corner[]>(initialCorners)
const [rotation, setRotation] = useState<0 | 90 | 180 | 270>(0)
const [draggingIndex, setDraggingIndex] = useState<number | null>(null)
const [displayScale, setDisplayScale] = useState(1)
const [isProcessing, setIsProcessing] = useState(false)
// Calculate display scale to fit source image in container
useEffect(() => {
const updateScale = () => {
if (!containerRef.current) return
const containerWidth = containerRef.current.clientWidth - 32 // padding
const containerHeight = containerRef.current.clientHeight * 0.5 // half for source
const scaleX = containerWidth / sourceCanvas.width
const scaleY = containerHeight / sourceCanvas.height
setDisplayScale(Math.min(scaleX, scaleY, 1))
}
updateScale()
window.addEventListener('resize', updateScale)
return () => window.removeEventListener('resize', updateScale)
}, [sourceCanvas.width, sourceCanvas.height])
// Update preview when corners or rotation change
useEffect(() => {
if (!previewCanvasRef.current || !cv) return
updatePreview()
}, [corners, rotation, cv])
const updatePreview = useCallback(() => {
const cvAny = cv as {
imread: (canvas: HTMLCanvasElement) => unknown
Mat: new () => unknown
matFromArray: (rows: number, cols: number, type: number, data: number[]) => unknown
Size: new (w: number, h: number) => unknown
Scalar: new () => unknown
getPerspectiveTransform: (src: unknown, dst: unknown) => unknown
warpPerspective: (
src: unknown,
dst: unknown,
M: unknown,
size: unknown,
flags: number,
borderMode: number,
borderValue: unknown
) => void
rotate: (src: unknown, dst: unknown, code: number) => void
imshow: (canvas: HTMLCanvasElement, mat: unknown) => void
CV_32FC2: number
INTER_LINEAR: number
BORDER_CONSTANT: number
ROTATE_90_CLOCKWISE: number
ROTATE_180: number
ROTATE_90_COUNTERCLOCKWISE: number
}
const previewCanvas = previewCanvasRef.current
if (!previewCanvas) return
// Helper to calculate distance
const distance = (p1: Corner, p2: Corner) =>
Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2)
try {
// Calculate output dimensions from corners
const width1 = distance(corners[0], corners[1])
const width2 = distance(corners[3], corners[2])
const height1 = distance(corners[0], corners[3])
const height2 = distance(corners[1], corners[2])
let outputWidth = Math.round((width1 + width2) / 2)
let outputHeight = Math.round((height1 + height2) / 2)
// Create perspective transform
const srcPts = cvAny.matFromArray(4, 1, cvAny.CV_32FC2, [
corners[0].x, corners[0].y,
corners[1].x, corners[1].y,
corners[2].x, corners[2].y,
corners[3].x, corners[3].y,
])
const dstPts = cvAny.matFromArray(4, 1, cvAny.CV_32FC2, [
0, 0,
outputWidth, 0,
outputWidth, outputHeight,
0, outputHeight,
])
const M = cvAny.getPerspectiveTransform(srcPts, dstPts)
const src = cvAny.imread(sourceCanvas)
const warped = new cvAny.Mat()
cvAny.warpPerspective(
src,
warped,
M,
new cvAny.Size(outputWidth, outputHeight),
cvAny.INTER_LINEAR,
cvAny.BORDER_CONSTANT,
new cvAny.Scalar()
)
// Apply rotation if needed
let final = warped
if (rotation !== 0) {
const rotated = new cvAny.Mat()
const rotateCode =
rotation === 90
? cvAny.ROTATE_90_CLOCKWISE
: rotation === 180
? cvAny.ROTATE_180
: cvAny.ROTATE_90_COUNTERCLOCKWISE
cvAny.rotate(warped, rotated, rotateCode)
;(warped as { delete: () => void }).delete()
final = rotated
// Swap dimensions for 90/270 rotation
if (rotation === 90 || rotation === 270) {
;[outputWidth, outputHeight] = [outputHeight, outputWidth]
}
}
// Update preview canvas size and show result
previewCanvas.width = outputWidth
previewCanvas.height = outputHeight
cvAny.imshow(previewCanvas, final)
// Clean up
;(srcPts as { delete: () => void }).delete()
;(dstPts as { delete: () => void }).delete()
;(M as { delete: () => void }).delete()
;(src as { delete: () => void }).delete()
;(final as { delete: () => void }).delete()
} catch (err) {
console.warn('Preview update failed:', err)
}
}, [corners, rotation, sourceCanvas, cv])
// Handle corner dragging
const handlePointerDown = useCallback(
(index: number) => (e: React.PointerEvent) => {
e.preventDefault()
e.stopPropagation()
setDraggingIndex(index)
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
},
[]
)
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (draggingIndex === null) return
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const x = (e.clientX - rect.left) / displayScale
const y = (e.clientY - rect.top) / displayScale
// Clamp to image bounds
const clampedX = Math.max(0, Math.min(sourceCanvas.width, x))
const clampedY = Math.max(0, Math.min(sourceCanvas.height, y))
setCorners((prev) => {
const next = [...prev]
next[draggingIndex] = { x: clampedX, y: clampedY }
return next
})
},
[draggingIndex, displayScale, sourceCanvas.width, sourceCanvas.height]
)
const handlePointerUp = useCallback(() => {
setDraggingIndex(null)
}, [])
// Handle rotation
const handleRotate = useCallback((direction: 'cw' | 'ccw') => {
setRotation((prev) => {
if (direction === 'cw') {
return ((prev + 90) % 360) as 0 | 90 | 180 | 270
} else {
return ((prev - 90 + 360) % 360) as 0 | 90 | 180 | 270
}
})
}, [])
// Handle confirm
const handleConfirm = useCallback(async () => {
if (!previewCanvasRef.current) return
setIsProcessing(true)
try {
// Force one final preview update
updatePreview()
const blob = await new Promise<Blob>((resolve, reject) => {
previewCanvasRef.current!.toBlob(
(b) => {
if (b) resolve(b)
else reject(new Error('Failed to create blob'))
},
'image/jpeg',
0.9
)
})
const file = new File([blob], `document-${Date.now()}.jpg`, {
type: 'image/jpeg',
})
onConfirm(file)
} catch (err) {
console.error('Failed to create file:', err)
} finally {
setIsProcessing(false)
}
}, [onConfirm, updatePreview])
const displayWidth = sourceCanvas.width * displayScale
const displayHeight = sourceCanvas.height * displayScale
return (
<div
ref={containerRef}
data-component="document-adjuster"
className={css({
position: 'absolute',
inset: 0,
bg: 'gray.900',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
})}
>
{/* Header */}
<div
className={css({
p: 4,
borderBottom: '1px solid',
borderColor: 'gray.700',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<button
type="button"
onClick={onCancel}
className={css({
px: 4,
py: 2,
color: 'white',
bg: 'transparent',
borderRadius: 'md',
cursor: 'pointer',
_hover: { bg: 'gray.800' },
})}
>
Back
</button>
<span className={css({ color: 'white', fontWeight: 'bold' })}>
Adjust Document
</span>
<button
type="button"
onClick={handleConfirm}
disabled={isProcessing}
className={css({
px: 4,
py: 2,
bg: 'green.500',
color: 'white',
borderRadius: 'md',
fontWeight: 'bold',
cursor: 'pointer',
_hover: { bg: 'green.600' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
{isProcessing ? 'Processing...' : 'Done ✓'}
</button>
</div>
{/* Source image with corner handles */}
<div
className={css({
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 4,
gap: 4,
overflow: 'auto',
})}
>
<div className={css({ color: 'gray.400', fontSize: 'sm', textAlign: 'center' })}>
Drag corners to adjust crop area
</div>
{/* Source image container */}
<div
className={css({
position: 'relative',
touchAction: 'none',
})}
style={{ width: displayWidth, height: displayHeight }}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
{/* Source image */}
<canvas
ref={(el) => {
if (el) {
el.width = sourceCanvas.width
el.height = sourceCanvas.height
const ctx = el.getContext('2d')
ctx?.drawImage(sourceCanvas, 0, 0)
}
}}
style={{
width: displayWidth,
height: displayHeight,
borderRadius: '8px',
}}
/>
{/* Quad overlay */}
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: displayWidth,
height: displayHeight,
pointerEvents: 'none',
}}
>
{/* Darkened area outside quad */}
<defs>
<mask id="quadMask">
<rect width="100%" height="100%" fill="white" />
<polygon
points={corners
.map((c) => `${c.x * displayScale},${c.y * displayScale}`)
.join(' ')}
fill="black"
/>
</mask>
</defs>
<rect
width="100%"
height="100%"
fill="rgba(0, 0, 0, 0.5)"
mask="url(#quadMask)"
/>
{/* Quad border */}
<polygon
points={corners
.map((c) => `${c.x * displayScale},${c.y * displayScale}`)
.join(' ')}
fill="none"
stroke="#22c55e"
strokeWidth="2"
/>
{/* Edge lines */}
{corners.map((corner, i) => {
const next = corners[(i + 1) % 4]
return (
<line
key={i}
x1={corner.x * displayScale}
y1={corner.y * displayScale}
x2={next.x * displayScale}
y2={next.y * displayScale}
stroke="#22c55e"
strokeWidth="2"
strokeDasharray="5,5"
/>
)
})}
</svg>
{/* Corner handles */}
{corners.map((corner, index) => (
<div
key={index}
data-element={`corner-handle-${index}`}
onPointerDown={handlePointerDown(index)}
className={css({
position: 'absolute',
width: '40px',
height: '40px',
borderRadius: 'full',
bg: 'green.500',
border: '3px solid white',
boxShadow: '0 2px 8px rgba(0,0,0,0.4)',
cursor: 'grab',
transform: 'translate(-50%, -50%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontWeight: 'bold',
fontSize: 'sm',
touchAction: 'none',
_active: { cursor: 'grabbing', bg: 'green.600' },
})}
style={{
left: corner.x * displayScale,
top: corner.y * displayScale,
}}
>
{index + 1}
</div>
))}
</div>
{/* Rotation controls */}
<div
className={css({
display: 'flex',
gap: 4,
alignItems: 'center',
})}
>
<button
type="button"
onClick={() => handleRotate('ccw')}
className={css({
width: '48px',
height: '48px',
bg: 'gray.700',
color: 'white',
borderRadius: 'full',
fontSize: '2xl',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
_hover: { bg: 'gray.600' },
})}
title="Rotate counter-clockwise"
>
</button>
<span className={css({ color: 'gray.400', fontSize: 'sm', minWidth: '60px', textAlign: 'center' })}>
{rotation}°
</span>
<button
type="button"
onClick={() => handleRotate('cw')}
className={css({
width: '48px',
height: '48px',
bg: 'gray.700',
color: 'white',
borderRadius: 'full',
fontSize: '2xl',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
_hover: { bg: 'gray.600' },
})}
title="Rotate clockwise"
>
</button>
</div>
{/* Preview */}
<div className={css({ color: 'gray.400', fontSize: 'sm', mt: 2 })}>
Preview
</div>
<div
className={css({
maxWidth: '100%',
maxHeight: '200px',
overflow: 'hidden',
borderRadius: 'lg',
border: '2px solid',
borderColor: 'gray.600',
})}
>
<canvas
ref={previewCanvasRef}
className={css({
maxWidth: '100%',
maxHeight: '200px',
objectFit: 'contain',
})}
/>
</div>
</div>
</div>
)
}
export default DocumentAdjuster

View File

@@ -195,6 +195,18 @@ export interface UseDocumentDetectionReturn {
isLocked: boolean
/** Debug information for troubleshooting */
debugInfo: DocumentDetectionDebugInfo
/** OpenCV reference for external use (e.g., DocumentAdjuster) */
cv: unknown
/**
* Get the current best quad's corner positions
* Returns null if no quad is detected
*/
getBestQuadCorners: () => Array<{ x: number; y: number }> | null
/**
* Capture the current video frame as a canvas
* Returns null if capture fails
*/
captureSourceFrame: (video: HTMLVideoElement) => HTMLCanvasElement | null
/**
* Draw detected document edges on overlay canvas
* Returns true if document was detected, false otherwise
@@ -1120,6 +1132,29 @@ export function useDocumentDetection(): UseDocumentDetectionReturn {
bestQuad.frameCount >= LOCKED_FRAME_COUNT &&
bestQuad.stabilityScore > 0.5
// Get current best quad corners
const getBestQuadCorners = useCallback((): Array<{ x: number; y: number }> | null => {
const quad = bestQuadRef.current
if (!quad) return null
return [...quad.corners]
}, [])
// Capture source frame (expose captureVideoFrame)
const captureSourceFrame = useCallback(
(video: HTMLVideoElement): HTMLCanvasElement | null => {
const frame = captureVideoFrame(video)
if (!frame) return null
// Return a copy so caller can keep it
const copy = document.createElement('canvas')
copy.width = frame.width
copy.height = frame.height
const ctx = copy.getContext('2d')
ctx?.drawImage(frame, 0, 0)
return copy
},
[captureVideoFrame]
)
return {
isLoading,
error,
@@ -1127,6 +1162,9 @@ export function useDocumentDetection(): UseDocumentDetectionReturn {
isStable,
isLocked: !!isLocked,
debugInfo,
cv: cvRef.current,
getBestQuadCorners,
captureSourceFrame,
highlightDocument,
extractDocument,
}