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:
@@ -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"
|
||||
|
||||
537
apps/web/src/components/practice/DocumentAdjuster.tsx
Normal file
537
apps/web/src/components/practice/DocumentAdjuster.tsx
Normal 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
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user