feat(practice): add document scanning with multi-quad tracking

Adds real-time document detection to the camera capture on session
summary pages. Uses OpenCV.js for edge detection and perspective
correction.

Key features:
- Multi-quad tracking: detects ALL quadrilaterals, not just largest
- Scores quads by stability over time (filters transient detections)
- Visual feedback: yellow (detecting) → green (stable) → bright green (locked)
- Auto-crops and deskews captured documents
- Falls back to raw photo if no document detected

Technical details:
- OpenCV.js (~8MB) lazy-loaded only when camera opens
- Tracks quads across frames by matching corner positions
- Filters by area (15-95% of frame) and document aspect ratios
- Locks on after 5 frames with 50%+ stability

🤖 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 11:57:50 -06:00
parent 9124a3182e
commit 5f4f1fde33
7 changed files with 1760 additions and 361 deletions

View File

@ -25,6 +25,12 @@ const nextConfig = {
layers: true,
}
// Exclude native Node.js modules from client bundle
// canvas is a jscanify dependency only needed for Node.js, not browser
if (!isServer) {
config.externals = [...(config.externals || []), 'canvas']
}
// Optimize WASM loading
if (!isServer) {
// Enable dynamic imports for better code splitting

View File

@ -79,6 +79,7 @@
"gray-matter": "^4.0.3",
"jose": "^6.1.0",
"js-yaml": "^4.1.0",
"jscanify": "^1.4.0",
"lib0": "^0.2.114",
"lucide-react": "^0.294.0",
"make-plural": "^7.4.0",

48
apps/web/public/opencv.js Normal file

File diff suppressed because one or more lines are too long

View File

@ -19,6 +19,7 @@ import {
StartPracticeModal,
} from '@/components/practice'
import { calculateAutoPauseInfo } from '@/components/practice/autoPauseCalculator'
import { useDocumentDetection } from '@/components/practice/useDocumentDetection'
import {
filterProblemsNeedingAttention,
getProblemsWithContext,
@ -29,6 +30,7 @@ import {
useSessionModeBanner,
} from '@/contexts/SessionModeBannerContext'
import { useTheme } from '@/contexts/ThemeContext'
import { VisualDebugProvider } from '@/contexts/VisualDebugContext'
import type { Player } from '@/db/schema/players'
import type { SessionPlan, SlotResult } from '@/db/schema/session-plans'
import { useSessionMode } from '@/hooks/useSessionMode'
@ -550,14 +552,16 @@ export function SummaryClient({
outline: 'none',
})}
>
<Dialog.Title className={css({ srOnly: true })}>Take Photo</Dialog.Title>
<Dialog.Description className={css({ srOnly: true })}>
Camera viewfinder. Tap capture to take a photo.
</Dialog.Description>
<FullscreenCamera
onCapture={handleCameraCapture}
onClose={() => setShowCamera(false)}
/>
<VisualDebugProvider>
<Dialog.Title className={css({ srOnly: true })}>Take Photo</Dialog.Title>
<Dialog.Description className={css({ srOnly: true })}>
Camera viewfinder. Tap capture to take a photo.
</Dialog.Description>
<FullscreenCamera
onCapture={handleCameraCapture}
onClose={() => setShowCamera(false)}
/>
</VisualDebugProvider>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
@ -578,11 +582,26 @@ interface FullscreenCameraProps {
function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const overlayCanvasRef = useRef<HTMLCanvasElement>(null)
const streamRef = useRef<MediaStream | null>(null)
const animationFrameRef = useRef<number | null>(null)
const lastDetectionRef = useRef<number>(0)
const [isReady, setIsReady] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isCapturing, setIsCapturing] = useState(false)
const [documentDetected, setDocumentDetected] = useState(false)
// Document detection hook (lazy loads OpenCV.js)
const {
isLoading: isScannerLoading,
isReady: isScannerReady,
isStable: isDetectionStable,
isLocked: isDetectionLocked,
debugInfo: scannerDebugInfo,
highlightDocument,
extractDocument,
} = useDocumentDetection()
useEffect(() => {
let cancelled = false
@ -632,6 +651,51 @@ function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps) {
}
}, [])
// Detection loop - runs when camera and scanner are ready
useEffect(() => {
if (!isReady || !isScannerReady) return
const video = videoRef.current
const overlay = overlayCanvasRef.current
if (!video || !overlay) return
// Sync overlay canvas size with video
const syncCanvasSize = () => {
if (overlay && video) {
overlay.width = video.clientWidth
overlay.height = video.clientHeight
}
}
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
}
animationFrameRef.current = requestAnimationFrame(detectLoop)
}
// Start detection loop
animationFrameRef.current = requestAnimationFrame(detectLoop)
// Sync on resize
window.addEventListener('resize', syncCanvasSize)
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
window.removeEventListener('resize', syncCanvasSize)
}
}, [isReady, isScannerReady, highlightDocument])
const capturePhoto = async () => {
if (!videoRef.current || !canvasRef.current) return
@ -639,17 +703,35 @@ function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps) {
try {
const video = videoRef.current
const canvas = canvasRef.current
let sourceCanvas: HTMLCanvasElement
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get canvas context')
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
// Try to extract document if scanner is ready
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')
if (!ctx) throw new Error('Could not get canvas context')
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
sourceCanvas = canvas
}
} else {
// Scanner not ready, use regular capture
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get canvas context')
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
sourceCanvas = canvas
}
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
sourceCanvas.toBlob(
(b) => {
if (b) resolve(b)
else reject(new Error('Failed to create blob'))
@ -694,6 +776,18 @@ function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps) {
})}
/>
{/* Overlay canvas for document detection visualization */}
<canvas
ref={overlayCanvasRef}
className={css({
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
})}
/>
<canvas ref={canvasRef} style={{ display: 'none' }} />
{!isReady && !error && (
@ -773,14 +867,131 @@ function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps) {
×
</button>
{/* Debug overlay panel - always shown to help diagnose detection */}
<div
data-element="scanner-debug-panel"
className={css({
position: 'absolute',
top: 4,
left: 4,
p: 3,
bg: 'rgba(0, 0, 0, 0.8)',
backdropFilter: 'blur(4px)',
borderRadius: 'lg',
color: 'white',
fontSize: 'xs',
fontFamily: 'monospace',
maxWidth: '280px',
zIndex: 10,
})}
>
<div className={css({ fontWeight: 'bold', mb: 2, color: 'yellow.400' })}>
Document Scanner Debug
</div>
<div className={css({ display: 'flex', flexDirection: 'column', gap: 1 })}>
<div>
Scanner:{' '}
<span className={css({ color: isScannerReady ? 'green.400' : 'orange.400' })}>
{isScannerLoading ? 'Loading...' : isScannerReady ? 'Ready' : 'Failed'}
</span>
</div>
<div>
Camera:{' '}
<span className={css({ color: isReady ? 'green.400' : 'orange.400' })}>
{isReady ? 'Ready' : 'Starting...'}
</span>
</div>
<div>
Document:{' '}
<span
className={css({
color: isDetectionLocked
? 'green.400'
: isDetectionStable
? 'green.300'
: documentDetected
? 'yellow.400'
: 'gray.400',
})}
>
{isDetectionLocked
? 'LOCKED'
: isDetectionStable
? 'Stable'
: documentDetected
? 'Unstable'
: 'Not detected'}
</span>
</div>
<div>
Quads: {scannerDebugInfo.quadsDetected} detected,{' '}
{scannerDebugInfo.trackedQuads} tracked
</div>
<div>
Best: {scannerDebugInfo.bestQuadFrameCount} frames,{' '}
{Math.round(scannerDebugInfo.bestQuadStability * 100)}% stable
</div>
{scannerDebugInfo.loadTimeMs !== null && (
<div>Load time: {scannerDebugInfo.loadTimeMs}ms</div>
)}
{scannerDebugInfo.lastDetectionMs !== null && (
<div>Detection: {scannerDebugInfo.lastDetectionMs}ms</div>
)}
{scannerDebugInfo.lastDetectionError && (
<div className={css({ color: 'red.400', wordBreak: 'break-word' })}>
Error: {scannerDebugInfo.lastDetectionError}
</div>
)}
</div>
</div>
<div
className={css({
position: 'absolute',
bottom: 8,
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 3,
})}
>
{/* Helper text for detection status */}
<div
data-element="detection-status"
className={css({
px: 4,
py: 2,
bg: 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(4px)',
borderRadius: 'full',
color: 'white',
fontSize: 'sm',
fontWeight: 'medium',
textAlign: 'center',
transition: 'all 0.2s',
})}
>
{isScannerLoading ? (
'Loading scanner...'
) : isDetectionLocked ? (
<span className={css({ color: 'green.400', fontWeight: 'bold' })}>
Hold steady - Ready to capture!
</span>
) : isDetectionStable ? (
<span className={css({ color: 'green.300' })}>
Document detected - Hold steady...
</span>
) : documentDetected ? (
<span className={css({ color: 'yellow.400' })}>
Detecting... hold camera steady
</span>
) : (
'Point camera at document'
)}
</div>
<button
type="button"
onClick={capturePhoto}
@ -794,9 +1005,17 @@ function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps) {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
boxShadow: isDetectionLocked
? '0 4px 30px rgba(0, 255, 100, 0.5)'
: '0 4px 20px rgba(0, 0, 0, 0.3)',
border: '4px solid',
borderColor: 'gray.300',
borderColor: isDetectionLocked
? 'green.400'
: isDetectionStable
? 'green.300'
: documentDetected
? 'yellow.400'
: 'gray.300',
transition: 'all 0.15s',
_hover: { transform: 'scale(1.05)' },
_active: { transform: 'scale(0.95)' },
@ -813,7 +1032,13 @@ function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps) {
bg: 'white',
borderRadius: 'full',
border: '2px solid',
borderColor: 'gray.400',
borderColor: isDetectionLocked
? 'green.400'
: isDetectionStable
? 'green.300'
: documentDetected
? 'yellow.400'
: 'gray.400',
})}
/>
)}

View File

@ -24,8 +24,6 @@ export interface OfflineWorkSectionProps {
dragOver: boolean
/** Dark mode */
isDark: boolean
/** Optional scrollspy section ID for navigation */
scrollspySection?: string
/** Handlers */
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void
onDrop: (e: React.DragEvent) => void
@ -37,15 +35,13 @@ export interface OfflineWorkSectionProps {
}
/**
* OfflineWorkSection - Photos of offline practice work + Coming Soon placeholder
* OfflineWorkSection - Unified gallery for offline practice photos
*
* Features:
* - Photo grid with 150px thumbnails
* - Click-to-expand in lightbox
* - Delete button on hover
* - File upload via button or drag-and-drop
* - Camera capture button
* - "Coming Soon" placeholder for AI analysis
* Design principles:
* - Single unified card (not two separate panes)
* - Gallery-first: add buttons ARE gallery tiles
* - Accommodates 1-8 scans gracefully
* - Coming Soon is a subtle footer, not a separate box
*/
export function OfflineWorkSection({
attachments,
@ -55,7 +51,6 @@ export function OfflineWorkSection({
deletingId,
dragOver,
isDark,
scrollspySection,
onFileSelect,
onDrop,
onDragOver,
@ -64,299 +59,346 @@ export function OfflineWorkSection({
onOpenLightbox,
onDeletePhoto,
}: OfflineWorkSectionProps) {
const hasPhotos = attachments.length > 0
const photoCount = attachments.length
// Show add tile unless we have 8+ photos (max reasonable gallery size)
const showAddTile = photoCount < 8
return (
<div
data-component="offline-work-section"
data-scrollspy-section={scrollspySection}
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1rem',
padding: '1.25rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '16px',
border: '2px solid',
borderColor: dragOver ? 'blue.400' : isDark ? 'gray.700' : 'gray.200',
borderStyle: dragOver ? 'dashed' : 'solid',
transition: 'border-color 0.2s, border-style 0.2s',
})}
>
{/* Photos Section - Drop Target */}
<div
data-section="session-photos"
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={onFileSelect}
className={css({ display: 'none' })}
/>
{/* Header */}
<h3
className={css({
padding: '1.5rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '16px',
border: '2px solid',
borderColor: dragOver ? 'blue.400' : isDark ? 'gray.700' : 'gray.200',
borderStyle: dragOver ? 'dashed' : 'solid',
transition: 'all 0.2s',
fontSize: '1rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '1rem',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={onFileSelect}
className={css({ display: 'none' })}
/>
{/* Header with action buttons */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1rem',
flexWrap: 'wrap',
gap: '0.5rem',
})}
>
<h3
<span>📝</span>
Offline Practice
{photoCount > 0 && (
<span
className={css({
fontSize: '1.125rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
<span>📝</span> Offline Practice
{hasPhotos && (
<span
className={css({
fontSize: '0.875rem',
fontWeight: 'normal',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
({attachments.length})
</span>
)}
</h3>
{/* Action buttons */}
<div className={css({ display: 'flex', gap: '0.5rem' })}>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className={css({
px: 3,
py: 1.5,
bg: isDark ? 'blue.600' : 'blue.500',
color: 'white',
borderRadius: 'md',
fontSize: 'sm',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { bg: isDark ? 'blue.500' : 'blue.600' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
{isUploading ? 'Uploading...' : 'Choose Files'}
</button>
<button
type="button"
onClick={onOpenCamera}
disabled={isUploading}
className={css({
px: 3,
py: 1.5,
bg: isDark ? 'green.600' : 'green.500',
color: 'white',
borderRadius: 'md',
fontSize: 'sm',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { bg: isDark ? 'green.500' : 'green.600' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
📷 Camera
</button>
</div>
</div>
{/* Upload error */}
{uploadError && (
<div
className={css({
mb: 3,
p: 2,
bg: 'red.50',
border: '1px solid',
borderColor: 'red.200',
borderRadius: 'md',
color: 'red.700',
fontSize: 'sm',
})}
>
{uploadError}
</div>
)}
{/* Photo grid or empty state */}
{hasPhotos ? (
<div
data-element="photo-grid"
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
gap: '0.75rem',
})}
>
{attachments.map((att, index) => (
<div
key={att.id}
data-element="photo-thumbnail"
className={css({
position: 'relative',
aspectRatio: '1',
borderRadius: 'lg',
overflow: 'hidden',
bg: 'gray.100',
cursor: 'pointer',
'&:hover [data-action="delete-photo"]': {
opacity: 1,
},
})}
>
{/* Clickable image */}
<button
type="button"
onClick={() => onOpenLightbox(index)}
className={css({
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
padding: 0,
border: 'none',
cursor: 'pointer',
})}
aria-label={`View photo ${index + 1}`}
>
{/* biome-ignore lint/a11y/useAltText: decorative thumbnail */}
{/* biome-ignore lint/performance/noImgElement: API-served images */}
<img
src={att.url}
className={css({
width: '100%',
height: '100%',
objectFit: 'cover',
})}
/>
</button>
{/* Delete button overlay */}
<button
type="button"
data-action="delete-photo"
onClick={(e) => {
e.stopPropagation()
onDeletePhoto(att.id)
}}
disabled={deletingId === att.id}
className={css({
position: 'absolute',
top: '0.5rem',
right: '0.5rem',
width: '28px',
height: '28px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
color: 'white',
borderRadius: 'full',
border: 'none',
cursor: 'pointer',
opacity: 0,
transition: 'opacity 0.2s',
fontSize: '1rem',
_hover: {
backgroundColor: 'red.600',
},
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
})}
aria-label="Delete photo"
>
{deletingId === att.id ? '...' : '×'}
</button>
</div>
))}
</div>
) : (
<div
data-element="empty-photos"
className={css({
textAlign: 'center',
py: 6,
fontSize: '0.875rem',
fontWeight: 'normal',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
<div className={css({ fontSize: '3rem', mb: 2 })}>📷</div>
<p className={css({ mb: 1 })}>No photos yet</p>
<p className={css({ fontSize: 'sm' })}>Upload photos of worksheets or practice work</p>
({photoCount})
</span>
)}
</h3>
{/* Upload error */}
{uploadError && (
<div
className={css({
mb: 3,
p: 2,
bg: isDark ? 'red.900/50' : 'red.50',
border: '1px solid',
borderColor: isDark ? 'red.700' : 'red.200',
borderRadius: 'md',
color: isDark ? 'red.300' : 'red.700',
fontSize: 'sm',
})}
>
{uploadError}
</div>
)}
{/* Unified Gallery Grid - photos + add tiles together */}
<div
data-element="photo-gallery"
className={css({
display: 'grid',
gap: '0.75rem',
// Responsive columns: 2 on mobile, 3 on tablet, 4 on desktop
gridTemplateColumns: 'repeat(2, 1fr)',
'@media (min-width: 480px)': {
gridTemplateColumns: 'repeat(3, 1fr)',
},
'@media (min-width: 768px)': {
gridTemplateColumns: 'repeat(4, 1fr)',
},
})}
>
{/* Existing photos */}
{attachments.map((att, index) => (
<div
key={att.id}
data-element="photo-tile"
className={css({
position: 'relative',
aspectRatio: '1',
borderRadius: '12px',
overflow: 'hidden',
bg: isDark ? 'gray.700' : 'gray.100',
cursor: 'pointer',
boxShadow: 'sm',
transition: 'transform 0.15s, box-shadow 0.15s',
_hover: {
transform: 'scale(1.02)',
boxShadow: 'md',
'& [data-action="delete-photo"]': {
opacity: 1,
},
},
})}
>
<button
type="button"
onClick={() => onOpenLightbox(index)}
className={css({
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
padding: 0,
border: 'none',
cursor: 'pointer',
bg: 'transparent',
})}
aria-label={`View photo ${index + 1}`}
>
{/* biome-ignore lint/a11y/useAltText: decorative thumbnail */}
{/* biome-ignore lint/performance/noImgElement: API-served images */}
<img
src={att.url}
className={css({
width: '100%',
height: '100%',
objectFit: 'cover',
})}
/>
</button>
{/* Delete button overlay */}
<button
type="button"
data-action="delete-photo"
onClick={(e) => {
e.stopPropagation()
onDeletePhoto(att.id)
}}
disabled={deletingId === att.id}
className={css({
position: 'absolute',
top: '0.5rem',
right: '0.5rem',
width: '28px',
height: '28px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
color: 'white',
borderRadius: 'full',
border: 'none',
cursor: 'pointer',
opacity: 0,
transition: 'opacity 0.2s, background-color 0.2s',
fontSize: '1rem',
_hover: {
backgroundColor: 'red.600',
},
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
})}
aria-label="Delete photo"
>
{deletingId === att.id ? '...' : '×'}
</button>
{/* Photo number badge */}
<div
className={css({
position: 'absolute',
bottom: '0.5rem',
left: '0.5rem',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
color: 'white',
borderRadius: 'full',
fontSize: '0.75rem',
fontWeight: 'bold',
})}
>
{index + 1}
</div>
</div>
))}
{/* Add tile - split into two instant-action zones */}
{showAddTile && (
<div
data-element="add-tile"
className={css({
position: 'relative',
aspectRatio: '1',
borderRadius: '12px',
border: '2px dashed',
borderColor: isDark ? 'gray.600' : 'gray.300',
backgroundColor: isDark ? 'gray.750' : 'gray.100',
overflow: 'hidden',
display: 'flex',
opacity: isUploading ? 0.5 : 1,
pointerEvents: isUploading ? 'none' : 'auto',
})}
>
{isUploading ? (
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
gap: '0.25rem',
})}
>
<span className={css({ fontSize: '1.5rem' })}></span>
<span
className={css({
fontSize: '0.6875rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Uploading...
</span>
</div>
) : (
<>
{/* Left half - Upload file */}
<button
type="button"
data-action="upload-file"
onClick={() => fileInputRef.current?.click()}
className={css({
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '0.25rem',
border: 'none',
backgroundColor: 'transparent',
cursor: 'pointer',
transition: 'background-color 0.15s',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.200',
},
})}
aria-label="Upload file"
>
<span className={css({ fontSize: '1.25rem' })}>📄</span>
<span
className={css({
fontSize: '0.625rem',
color: isDark ? 'gray.500' : 'gray.500',
})}
>
Upload
</span>
</button>
{/* Divider */}
<div
className={css({
width: '1px',
backgroundColor: isDark ? 'gray.600' : 'gray.300',
})}
/>
{/* Right half - Take photo */}
<button
type="button"
data-action="take-photo"
onClick={onOpenCamera}
className={css({
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '0.25rem',
border: 'none',
backgroundColor: 'transparent',
cursor: 'pointer',
transition: 'background-color 0.15s',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.200',
},
})}
aria-label="Take photo"
>
<span className={css({ fontSize: '1.25rem' })}>📷</span>
<span
className={css({
fontSize: '0.625rem',
color: isDark ? 'gray.500' : 'gray.500',
})}
>
Camera
</span>
</button>
</>
)}
</div>
)}
</div>
{/* Coming Soon - AI Analysis Placeholder */}
{/* Coming Soon footer - subtle, integrated */}
<div
data-section="coming-soon"
data-element="coming-soon-hint"
className={css({
padding: '1rem',
backgroundColor: isDark ? 'gray.800/50' : 'gray.50',
borderRadius: '12px',
border: '1px solid',
marginTop: '1rem',
paddingTop: '0.75rem',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
color: isDark ? 'gray.500' : 'gray.500',
fontSize: '0.8125rem',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'flex-start',
gap: '0.75rem',
})}
>
<span
className={css({
fontSize: '1.5rem',
lineHeight: 1,
})}
>
🔮
</span>
<div>
<h4
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'gray.300' : 'gray.700',
marginBottom: '0.25rem',
})}
>
Coming Soon
</h4>
<p
className={css({
fontSize: '0.8125rem',
color: isDark ? 'gray.400' : 'gray.600',
lineHeight: 1.4,
})}
>
We'll soon analyze your worksheets and automatically track problems completed, just
like online practice!
</p>
</div>
</div>
<span>🔮</span>
<span>Coming soon: Auto-analyze worksheets to track progress</span>
</div>
</div>
)

View File

@ -0,0 +1,943 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* Hook for document detection using OpenCV.js directly
*
* Features:
* - Lazy loads OpenCV.js (~8MB) only when first used
* - Multi-quad tracking: detects ALL quadrilaterals, not just the largest
* - Scores quads by: size, aspect ratio, and temporal stability
* - Filters out small quads (likely printed on page) vs page-sized quads
* - Provides highlightDocument for drawing detected quad on overlay
* - Provides extractDocument for cropping/deskewing captured image
*/
// OpenCV.js types (minimal interface for what we use)
interface CVMat {
delete: () => void
data32S: Int32Array
rows: number
cols: number
}
interface CVMatVector {
size: () => number
get: (i: number) => CVMat
delete: () => void
}
interface CVSize {
width: number
height: number
}
interface CVPoint {
x: number
y: number
}
interface CV {
Mat: new () => CVMat
MatVector: new () => CVMatVector
Size: new (w: number, h: number) => CVSize
Scalar: new (r?: number, g?: number, b?: number, a?: number) => unknown
imread: (canvas: HTMLCanvasElement) => CVMat
imshow: (canvas: HTMLCanvasElement, mat: CVMat) => void
cvtColor: (src: CVMat, dst: CVMat, code: number) => void
GaussianBlur: (
src: CVMat,
dst: CVMat,
size: CVSize,
sigmaX: number,
sigmaY: number,
borderType: number
) => void
Canny: (src: CVMat, dst: CVMat, t1: number, t2: number) => void
dilate: (
src: CVMat,
dst: CVMat,
kernel: CVMat,
anchor: CVPoint,
iterations: number
) => void
findContours: (
src: CVMat,
contours: CVMatVector,
hierarchy: CVMat,
mode: number,
method: number
) => void
contourArea: (contour: CVMat) => number
arcLength: (contour: CVMat, closed: boolean) => number
approxPolyDP: (
contour: CVMat,
approx: CVMat,
epsilon: number,
closed: boolean
) => void
getPerspectiveTransform: (src: CVMat, dst: CVMat) => CVMat
warpPerspective: (
src: CVMat,
dst: CVMat,
M: CVMat,
size: CVSize,
flags: number,
borderMode: number,
borderValue: unknown
) => void
matFromArray: (
rows: number,
cols: number,
type: number,
data: number[]
) => CVMat
COLOR_RGBA2GRAY: number
BORDER_DEFAULT: number
RETR_LIST: number
CHAIN_APPROX_SIMPLE: number
CV_32FC2: number
INTER_LINEAR: number
BORDER_CONSTANT: number
}
/** Represents a detected quadrilateral with corner points */
interface DetectedQuad {
corners: Array<{ x: number; y: number }>
area: number
aspectRatio: number
// Unique ID based on approximate center position
centerId: string
}
/** Tracked quad candidate with history */
interface TrackedQuad {
id: string
corners: Array<{ x: number; y: number }>
area: number
aspectRatio: number
/** How many frames this quad has been seen */
frameCount: number
/** Last frame number when this quad was seen */
lastSeenFrame: number
/** Stability score based on corner consistency */
stabilityScore: number
/** History of corner positions for stability calculation */
cornerHistory: Array<Array<{ x: number; y: number }>>
}
export interface DocumentDetectionDebugInfo {
/** Time taken to load OpenCV in ms */
loadTimeMs: number | null
/** Last detection attempt time in ms */
lastDetectionMs: number | null
/** Number of quads detected this frame */
quadsDetected: number
/** Number of tracked quad candidates */
trackedQuads: number
/** Best quad's stability score */
bestQuadStability: number
/** Best quad's frame count */
bestQuadFrameCount: number
/** Last error message from detection */
lastDetectionError: string | null
}
/** Number of frames to track quad history */
const HISTORY_LENGTH = 10
/** Minimum frames a quad must be seen to be considered stable */
const MIN_FRAMES_FOR_STABLE = 3
/** Minimum frames for "locked" state */
const LOCKED_FRAME_COUNT = 5
/** Maximum distance (as % of frame diagonal) for quads to be considered "same" */
const QUAD_MATCH_THRESHOLD = 0.08
/** Minimum area as % of frame for a quad to be considered page-sized */
const MIN_AREA_RATIO = 0.15
/** Maximum area as % of frame (filter out frame edges detected as quad) */
const MAX_AREA_RATIO = 0.95
/** Expected aspect ratios for documents (width/height) */
const EXPECTED_ASPECT_RATIOS = [
8.5 / 11, // US Letter portrait
11 / 8.5, // US Letter landscape
1 / Math.sqrt(2), // A4 portrait
Math.sqrt(2), // A4 landscape
1, // Square
]
/** How close aspect ratio must be to expected (tolerance) */
const ASPECT_RATIO_TOLERANCE = 0.3
export interface UseDocumentDetectionReturn {
/** Whether OpenCV is still loading */
isLoading: boolean
/** Error message if loading failed */
error: string | null
/** Whether scanner is ready to use */
isReady: boolean
/** Whether detection is currently stable (good time to capture) */
isStable: boolean
/** Whether detection is locked (very stable, ideal to capture) */
isLocked: boolean
/** Debug information for troubleshooting */
debugInfo: DocumentDetectionDebugInfo
/**
* Draw detected document edges on overlay canvas
* Returns true if document was detected, false otherwise
*/
highlightDocument: (
video: HTMLVideoElement,
canvas: HTMLCanvasElement
) => boolean
/**
* Extract and deskew the detected document
* Returns canvas with cropped document, or null if extraction failed
*/
extractDocument: (video: HTMLVideoElement) => HTMLCanvasElement | null
}
export function useDocumentDetection(): UseDocumentDetectionReturn {
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const cvRef = useRef<CV | null>(null)
// Multi-quad tracking
const trackedQuadsRef = useRef<Map<string, TrackedQuad>>(new Map())
const frameCountRef = useRef(0)
const bestQuadRef = useRef<TrackedQuad | null>(null)
const lastStableFrameRef = useRef<HTMLCanvasElement | null>(null)
// Debug info tracking
const [debugInfo, setDebugInfo] = useState<DocumentDetectionDebugInfo>({
loadTimeMs: null,
lastDetectionMs: null,
quadsDetected: 0,
trackedQuads: 0,
bestQuadStability: 0,
bestQuadFrameCount: 0,
lastDetectionError: null,
})
const loadStartTimeRef = useRef<number>(Date.now())
// Lazy load OpenCV.js
useEffect(() => {
let mounted = true
// Helper to check if OpenCV is fully initialized
const isOpenCVReady = (): boolean => {
const cv = (window as unknown as { cv?: { imread?: unknown } }).cv
return !!(cv && typeof cv.imread === 'function')
}
async function loadOpenCV() {
try {
if (typeof window !== 'undefined') {
if (!isOpenCVReady()) {
const existingScript = document.querySelector(
'script[src="/opencv.js"]'
)
if (!existingScript) {
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script')
script.src = '/opencv.js'
script.async = true
script.onload = () => {
const checkReady = () => {
if (isOpenCVReady()) {
resolve()
} else {
const cv = (
window as unknown as {
cv?: { onRuntimeInitialized?: () => void }
}
).cv
if (cv) {
const previousCallback = cv.onRuntimeInitialized
cv.onRuntimeInitialized = () => {
previousCallback?.()
resolve()
}
} else {
reject(new Error('OpenCV.js loaded but cv not found'))
}
}
}
checkReady()
}
script.onerror = () =>
reject(new Error('Failed to load OpenCV.js'))
document.head.appendChild(script)
})
} else {
await new Promise<void>((resolve, reject) => {
const maxWait = 30000
const startTime = Date.now()
const checkReady = () => {
if (isOpenCVReady()) {
resolve()
} else if (Date.now() - startTime > maxWait) {
reject(new Error('OpenCV.js loading timed out'))
} else {
const cv = (
window as unknown as {
cv?: { onRuntimeInitialized?: () => void }
}
).cv
if (cv) {
const previousCallback = cv.onRuntimeInitialized
cv.onRuntimeInitialized = () => {
previousCallback?.()
resolve()
}
} else {
setTimeout(checkReady, 100)
}
}
}
checkReady()
})
}
}
}
if (!mounted) return
// Store OpenCV reference
cvRef.current = (window as unknown as { cv: CV }).cv
const loadTime = Date.now() - loadStartTimeRef.current
setDebugInfo((prev) => ({ ...prev, loadTimeMs: loadTime }))
setIsLoading(false)
} catch (err) {
if (!mounted) return
console.error('Failed to load OpenCV:', err)
setError(
err instanceof Error ? err.message : 'Failed to load OpenCV'
)
setIsLoading(false)
}
}
loadOpenCV()
return () => {
mounted = false
}
}, [])
// Reusable canvas for video frame capture
const frameCanvasRef = useRef<HTMLCanvasElement | null>(null)
// Helper to capture video frame to canvas
const captureVideoFrame = useCallback(
(video: HTMLVideoElement): HTMLCanvasElement | null => {
if (!video.videoWidth || !video.videoHeight) return null
if (!frameCanvasRef.current) {
frameCanvasRef.current = document.createElement('canvas')
}
const frameCanvas = frameCanvasRef.current
if (
frameCanvas.width !== video.videoWidth ||
frameCanvas.height !== video.videoHeight
) {
frameCanvas.width = video.videoWidth
frameCanvas.height = video.videoHeight
}
const ctx = frameCanvas.getContext('2d')
if (!ctx) return null
ctx.drawImage(video, 0, 0)
return frameCanvas
},
[]
)
// Calculate distance between two points
const distance = useCallback(
(p1: { x: number; y: number }, p2: { x: number; y: number }): number => {
return Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2)
},
[]
)
// Order corners: top-left, top-right, bottom-right, bottom-left
const orderCorners = useCallback(
(
corners: Array<{ x: number; y: number }>
): Array<{ x: number; y: number }> => {
if (corners.length !== 4) return corners
// Find centroid
const cx = corners.reduce((s, c) => s + c.x, 0) / 4
const cy = corners.reduce((s, c) => s + c.y, 0) / 4
// Sort by angle from centroid
const sorted = [...corners].sort((a, b) => {
const angleA = Math.atan2(a.y - cy, a.x - cx)
const angleB = Math.atan2(b.y - cy, b.x - cx)
return angleA - angleB
})
// Find top-left (smallest x+y)
let topLeftIdx = 0
let minSum = Infinity
for (let i = 0; i < 4; i++) {
const sum = sorted[i].x + sorted[i].y
if (sum < minSum) {
minSum = sum
topLeftIdx = i
}
}
// Rotate array so top-left is first
const ordered = []
for (let i = 0; i < 4; i++) {
ordered.push(sorted[(topLeftIdx + i) % 4])
}
return ordered
},
[]
)
// Check if aspect ratio is document-like
const isDocumentAspectRatio = useCallback((ratio: number): boolean => {
return EXPECTED_ASPECT_RATIOS.some(
(expected) => Math.abs(ratio - expected) < ASPECT_RATIO_TOLERANCE
)
}, [])
// Generate a stable ID for a quad based on its center position
const getQuadCenterId = useCallback(
(
corners: Array<{ x: number; y: number }>,
frameWidth: number,
frameHeight: number
): string => {
const cx = corners.reduce((s, c) => s + c.x, 0) / 4
const cy = corners.reduce((s, c) => s + c.y, 0) / 4
// Quantize to grid cells (10x10 grid)
const gridX = Math.floor((cx / frameWidth) * 10)
const gridY = Math.floor((cy / frameHeight) * 10)
return `${gridX},${gridY}`
},
[]
)
// Check if two quads are similar (same document)
const quadsMatch = useCallback(
(
q1: Array<{ x: number; y: number }>,
q2: Array<{ x: number; y: number }>,
frameDiagonal: number
): boolean => {
const threshold = frameDiagonal * QUAD_MATCH_THRESHOLD
let totalDist = 0
for (let i = 0; i < 4; i++) {
totalDist += distance(q1[i], q2[i])
}
return totalDist / 4 < threshold
},
[distance]
)
// Calculate corner stability (how much corners move between frames)
const calculateCornerStability = useCallback(
(history: Array<Array<{ x: number; y: number }>>): number => {
if (history.length < 2) return 0
let totalVariance = 0
for (let corner = 0; corner < 4; corner++) {
const xs = history.map((h) => h[corner].x)
const ys = history.map((h) => h[corner].y)
const meanX = xs.reduce((a, b) => a + b, 0) / xs.length
const meanY = ys.reduce((a, b) => a + b, 0) / ys.length
const varX =
xs.reduce((a, b) => a + (b - meanX) ** 2, 0) / xs.length
const varY =
ys.reduce((a, b) => a + (b - meanY) ** 2, 0) / ys.length
totalVariance += Math.sqrt(varX + varY)
}
// Convert variance to stability score (lower variance = higher stability)
// Normalize: variance of 0 = stability 1, variance of 50+ = stability 0
const avgVariance = totalVariance / 4
return Math.max(0, 1 - avgVariance / 50)
},
[]
)
// Find all quadrilaterals in the frame using OpenCV
const findAllQuads = useCallback(
(frameCanvas: HTMLCanvasElement): DetectedQuad[] => {
const cv = cvRef.current
if (!cv) return []
const quads: DetectedQuad[] = []
const frameArea = frameCanvas.width * frameCanvas.height
const frameDiagonal = Math.sqrt(
frameCanvas.width ** 2 + frameCanvas.height ** 2
)
// OpenCV processing
let src: CVMat | null = null
let gray: CVMat | null = null
let blurred: CVMat | null = null
let edges: CVMat | null = null
let contours: CVMatVector | null = null
let hierarchy: CVMat | null = null
try {
src = cv.imread(frameCanvas)
gray = new cv.Mat()
blurred = new cv.Mat()
edges = new cv.Mat()
// Convert to grayscale
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY)
// Blur to reduce noise
cv.GaussianBlur(
gray,
blurred,
new cv.Size(5, 5),
0,
0,
cv.BORDER_DEFAULT
)
// Edge detection
cv.Canny(blurred, edges, 50, 150)
// Dilate edges to connect gaps
const kernel = new cv.Mat()
cv.dilate(edges, edges, kernel, { x: -1, y: -1 } as CVPoint, 1)
kernel.delete()
// Find contours
contours = new cv.MatVector()
hierarchy = new cv.Mat()
cv.findContours(
edges,
contours,
hierarchy,
cv.RETR_LIST,
cv.CHAIN_APPROX_SIMPLE
)
// Process each contour
for (let i = 0; i < contours.size(); i++) {
const contour = contours.get(i)
const area = cv.contourArea(contour)
const areaRatio = area / frameArea
// Skip if too small or too large
if (areaRatio < MIN_AREA_RATIO || areaRatio > MAX_AREA_RATIO) {
continue
}
// Approximate to polygon
const approx = new cv.Mat()
const perimeter = cv.arcLength(contour, true)
cv.approxPolyDP(contour, approx, 0.02 * perimeter, true)
// Check if it's a quadrilateral
if (approx.rows === 4) {
// Extract corners
const corners: Array<{ x: number; y: number }> = []
for (let j = 0; j < 4; j++) {
corners.push({
x: approx.data32S[j * 2],
y: approx.data32S[j * 2 + 1],
})
}
// Order corners consistently
const orderedCorners = orderCorners(corners)
// Calculate aspect ratio
const width = distance(orderedCorners[0], orderedCorners[1])
const height = distance(orderedCorners[1], orderedCorners[2])
const aspectRatio = Math.max(width, height) / Math.min(width, height)
// Check if aspect ratio is document-like
if (isDocumentAspectRatio(aspectRatio)) {
quads.push({
corners: orderedCorners,
area,
aspectRatio,
centerId: getQuadCenterId(
orderedCorners,
frameCanvas.width,
frameCanvas.height
),
})
}
}
approx.delete()
}
} finally {
// Clean up OpenCV memory
src?.delete()
gray?.delete()
blurred?.delete()
edges?.delete()
contours?.delete()
hierarchy?.delete()
}
// Sort by area (largest first)
quads.sort((a, b) => b.area - a.area)
return quads
},
[distance, orderCorners, isDocumentAspectRatio, getQuadCenterId]
)
// Update tracked quads with new detections
const updateTrackedQuads = useCallback(
(
detectedQuads: DetectedQuad[],
frameWidth: number,
frameHeight: number
): TrackedQuad | null => {
const currentFrame = frameCountRef.current++
const trackedQuads = trackedQuadsRef.current
const frameDiagonal = Math.sqrt(frameWidth ** 2 + frameHeight ** 2)
// Mark all tracked quads as not seen this frame
const seenIds = new Set<string>()
// Match detected quads to tracked quads
for (const detected of detectedQuads) {
let matched = false
for (const [id, tracked] of trackedQuads) {
if (
!seenIds.has(id) &&
quadsMatch(detected.corners, tracked.corners, frameDiagonal)
) {
// Update existing tracked quad
tracked.corners = detected.corners
tracked.area = detected.area
tracked.aspectRatio = detected.aspectRatio
tracked.frameCount++
tracked.lastSeenFrame = currentFrame
tracked.cornerHistory.push(detected.corners)
if (tracked.cornerHistory.length > HISTORY_LENGTH) {
tracked.cornerHistory.shift()
}
tracked.stabilityScore = calculateCornerStability(
tracked.cornerHistory
)
seenIds.add(id)
matched = true
break
}
}
if (!matched) {
// New quad - start tracking
const newId = `quad_${currentFrame}_${Math.random().toString(36).slice(2, 8)}`
trackedQuads.set(newId, {
id: newId,
corners: detected.corners,
area: detected.area,
aspectRatio: detected.aspectRatio,
frameCount: 1,
lastSeenFrame: currentFrame,
stabilityScore: 0,
cornerHistory: [detected.corners],
})
seenIds.add(newId)
}
}
// Remove quads not seen for a while
for (const [id, tracked] of trackedQuads) {
if (currentFrame - tracked.lastSeenFrame > 3) {
trackedQuads.delete(id)
}
}
// Find best quad (highest score = frameCount * stability * area)
let bestQuad: TrackedQuad | null = null
let bestScore = 0
for (const tracked of trackedQuads.values()) {
// Only consider quads seen recently
if (currentFrame - tracked.lastSeenFrame > 2) continue
// Score: prioritize stability and longevity, then area
const score =
tracked.frameCount *
(0.5 + tracked.stabilityScore) *
Math.sqrt(tracked.area)
if (score > bestScore) {
bestScore = score
bestQuad = tracked
}
}
bestQuadRef.current = bestQuad
return bestQuad
},
[quadsMatch, calculateCornerStability]
)
// Draw quad on overlay canvas
const drawQuad = useCallback(
(
ctx: CanvasRenderingContext2D,
corners: Array<{ x: number; y: number }>,
color: string,
lineWidth: number
) => {
ctx.strokeStyle = color
ctx.lineWidth = lineWidth
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.beginPath()
ctx.moveTo(corners[0].x, corners[0].y)
for (let i = 1; i < corners.length; i++) {
ctx.lineTo(corners[i].x, corners[i].y)
}
ctx.closePath()
ctx.stroke()
// Draw corner circles
ctx.fillStyle = color
for (const corner of corners) {
ctx.beginPath()
ctx.arc(corner.x, corner.y, lineWidth * 2, 0, Math.PI * 2)
ctx.fill()
}
},
[]
)
const highlightDocument = useCallback(
(video: HTMLVideoElement, overlayCanvas: HTMLCanvasElement): boolean => {
const cv = cvRef.current
if (!cv) return false
const startTime = performance.now()
try {
const frameCanvas = captureVideoFrame(video)
if (!frameCanvas) {
setDebugInfo((prev) => ({
...prev,
lastDetectionError: 'Failed to capture video frame',
}))
return false
}
// Resize overlay to match video
if (
overlayCanvas.width !== video.videoWidth ||
overlayCanvas.height !== video.videoHeight
) {
overlayCanvas.width = video.videoWidth
overlayCanvas.height = video.videoHeight
}
const overlayCtx = overlayCanvas.getContext('2d')
if (!overlayCtx) return false
// Clear overlay
overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height)
// Find all quads in this frame
const detectedQuads = findAllQuads(frameCanvas)
// Update tracking and get best quad
const bestQuad = updateTrackedQuads(
detectedQuads,
frameCanvas.width,
frameCanvas.height
)
const detectionTime = performance.now() - startTime
// Draw all detected quads (faded) for debugging
for (const quad of detectedQuads) {
if (bestQuad && quad.centerId === bestQuad.id) continue
drawQuad(overlayCtx, quad.corners, 'rgba(100, 100, 100, 0.3)', 2)
}
// Draw best quad with color based on stability
if (bestQuad) {
const isStable = bestQuad.frameCount >= MIN_FRAMES_FOR_STABLE
const isLocked = bestQuad.frameCount >= LOCKED_FRAME_COUNT
let color: string
let lineWidth: number
if (isLocked && bestQuad.stabilityScore > 0.5) {
color = 'rgba(0, 255, 100, 0.95)'
lineWidth = 6
// Save stable frame
if (!lastStableFrameRef.current) {
lastStableFrameRef.current = document.createElement('canvas')
}
lastStableFrameRef.current.width = frameCanvas.width
lastStableFrameRef.current.height = frameCanvas.height
const stableCtx = lastStableFrameRef.current.getContext('2d')
stableCtx?.drawImage(frameCanvas, 0, 0)
} else if (isStable) {
color = 'rgba(100, 255, 100, 0.85)'
lineWidth = 5
} else {
color = 'rgba(255, 200, 0, 0.8)'
lineWidth = 4
}
drawQuad(overlayCtx, bestQuad.corners, color, lineWidth)
}
// Update debug info
setDebugInfo((prev) => ({
...prev,
lastDetectionMs: Math.round(detectionTime),
quadsDetected: detectedQuads.length,
trackedQuads: trackedQuadsRef.current.size,
bestQuadStability: bestQuad?.stabilityScore ?? 0,
bestQuadFrameCount: bestQuad?.frameCount ?? 0,
lastDetectionError: null,
}))
return !!bestQuad
} catch (err) {
setDebugInfo((prev) => ({
...prev,
lastDetectionError:
err instanceof Error ? err.message : 'Unknown error',
}))
return false
}
},
[captureVideoFrame, findAllQuads, updateTrackedQuads, drawQuad]
)
const extractDocument = useCallback(
(video: HTMLVideoElement): HTMLCanvasElement | null => {
const cv = cvRef.current
const bestQuad = bestQuadRef.current
if (!cv || !bestQuad) return null
try {
// Use stable frame if available, otherwise capture current
const sourceCanvas =
lastStableFrameRef.current &&
bestQuad.frameCount >= LOCKED_FRAME_COUNT
? lastStableFrameRef.current
: captureVideoFrame(video)
if (!sourceCanvas) return null
const corners = bestQuad.corners
// Calculate output dimensions (maintain aspect ratio)
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])
const outputWidth = Math.round((width1 + width2) / 2)
const outputHeight = Math.round((height1 + height2) / 2)
// Create source points matrix
const srcPts = cv.matFromArray(4, 1, cv.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,
])
// Create destination points matrix
const dstPts = cv.matFromArray(4, 1, cv.CV_32FC2, [
0,
0,
outputWidth,
0,
outputWidth,
outputHeight,
0,
outputHeight,
])
// Get perspective transform
const M = cv.getPerspectiveTransform(srcPts, dstPts)
// Read source image
const src = cv.imread(sourceCanvas)
// Create output mat
const dst = new cv.Mat()
// Apply perspective warp
cv.warpPerspective(
src,
dst,
M,
new cv.Size(outputWidth, outputHeight),
cv.INTER_LINEAR,
cv.BORDER_CONSTANT,
new cv.Scalar()
)
// Create output canvas
const outputCanvas = document.createElement('canvas')
outputCanvas.width = outputWidth
outputCanvas.height = outputHeight
cv.imshow(outputCanvas, dst)
// Clean up
srcPts.delete()
dstPts.delete()
M.delete()
src.delete()
dst.delete()
return outputCanvas
} catch (err) {
console.warn('Document extraction failed:', err)
return null
}
},
[captureVideoFrame, distance]
)
// Compute derived state
const bestQuad = bestQuadRef.current
const isStable = bestQuad ? bestQuad.frameCount >= MIN_FRAMES_FOR_STABLE : false
const isLocked =
bestQuad &&
bestQuad.frameCount >= LOCKED_FRAME_COUNT &&
bestQuad.stabilityScore > 0.5
return {
isLoading,
error,
isReady: !isLoading && !error && cvRef.current !== null,
isStable,
isLocked: !!isLocked,
debugInfo,
highlightDocument,
extractDocument,
}
}
export default useDocumentDetection

View File

@ -67,7 +67,7 @@ importers:
version: 0.5.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@pandacss/dev':
specifier: ^0.20.0
version: 0.20.1(jsdom@27.0.0(postcss@8.5.6))(typescript@5.9.3)
version: 0.20.1(jsdom@27.0.0(canvas@3.2.0)(postcss@8.5.6))(typescript@5.9.3)
'@paralleldrive/cuid2':
specifier: ^2.2.2
version: 2.2.2
@ -206,6 +206,9 @@ importers:
js-yaml:
specifier: ^4.1.0
version: 4.1.0
jscanify:
specifier: ^1.4.0
version: 1.4.0
lib0:
specifier: ^0.2.114
version: 0.2.114
@ -374,7 +377,7 @@ importers:
version: 18.0.1
jsdom:
specifier: ^27.0.0
version: 27.0.0(postcss@8.5.6)
version: 27.0.0(canvas@3.2.0)(postcss@8.5.6)
storybook:
specifier: ^9.1.7
version: 9.1.10(@testing-library/dom@9.3.4)(prettier@3.6.2)(vite@5.4.20(@types/node@20.19.19)(terser@5.44.0))
@ -389,7 +392,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^1.0.0
version: 1.6.1(@types/node@20.19.19)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jsdom@27.0.0(postcss@8.5.6))(terser@5.44.0)
version: 1.6.1(@types/node@20.19.19)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jsdom@27.0.0(canvas@3.2.0)(postcss@8.5.6))(terser@5.44.0)
packages/abacus-react:
dependencies:
@ -474,10 +477,10 @@ importers:
version: 7.0.2
jest-environment-jsdom:
specifier: ^30.1.2
version: 30.2.0
version: 30.2.0(canvas@3.2.0)
jsdom:
specifier: ^27.0.0
version: 27.0.0(postcss@8.5.6)
version: 27.0.0(canvas@3.2.0)(postcss@8.5.6)
react:
specifier: ^18.2.0
version: 18.3.1
@ -501,7 +504,7 @@ importers:
version: 4.5.14(@types/node@20.19.19)(terser@5.44.0)
vitest:
specifier: ^1.0.0
version: 1.6.1(@types/node@20.19.19)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jsdom@27.0.0(postcss@8.5.6))(terser@5.44.0)
version: 1.6.1(@types/node@20.19.19)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jsdom@27.0.0(canvas@3.2.0)(postcss@8.5.6))(terser@5.44.0)
packages/core/client/node:
devDependencies:
@ -516,7 +519,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^1.0.0
version: 1.6.1(@types/node@20.19.19)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jsdom@27.0.0(postcss@8.5.6))(terser@5.44.0)
version: 1.6.1(@types/node@20.19.19)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jsdom@27.0.0(canvas@3.2.0)(postcss@8.5.6))(terser@5.44.0)
packages/templates:
dependencies:
@ -4976,6 +4979,9 @@ packages:
browser-assert@1.2.1:
resolution: {integrity: sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==}
browser-stdout@1.3.1:
resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==}
browserify-aes@1.2.0:
resolution: {integrity: sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==}
@ -5069,6 +5075,10 @@ packages:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
camelcase@6.3.0:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
camera-controls@2.10.1:
resolution: {integrity: sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==}
peerDependencies:
@ -5083,6 +5093,10 @@ packages:
canvas-confetti@1.9.4:
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
canvas@3.2.0:
resolution: {integrity: sha512-jk0GxrLtUEmW/TmFsk2WghvgHe8B0pxGilqCL21y8lHkPUGa6FTsnCNtHPOzT8O3y+N+m3espawV80bbBlgfTA==}
engines: {node: ^18.12.0 || >= 20.9.0}
cardinal@2.1.1:
resolution: {integrity: sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==}
hasBin: true
@ -5138,6 +5152,10 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chord-voicings@0.0.1:
resolution: {integrity: sha512-SutgB/4ynkkuiK6qdQ/k3QvCFcH0Vj8Ch4t6LbRyRQbVzP/TOztiCk3kvXd516UZ6fqk7ijDRELEFcKN+6V8sA==}
@ -5547,6 +5565,10 @@ packages:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decamelize@4.0.0:
resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==}
engines: {node: '>=10'}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@ -5664,6 +5686,10 @@ packages:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
diff@7.0.0:
resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==}
engines: {node: '>=0.3.1'}
diffie-hellman@5.0.3:
resolution: {integrity: sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==}
@ -6386,6 +6412,10 @@ packages:
resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==}
engines: {node: ^10.12.0 || >=12.0.0}
flat@5.0.2:
resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==}
hasBin: true
flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
@ -7000,6 +7030,10 @@ packages:
resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
engines: {node: '>=8'}
is-plain-obj@2.1.0:
resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==}
engines: {node: '>=8'}
is-plain-obj@4.1.0:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
@ -7220,6 +7254,9 @@ packages:
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
hasBin: true
jscanify@1.4.0:
resolution: {integrity: sha512-5wVTrZfQOBoxNeHcP5n971xHGm61126XvurKNPZs8bFg07z2KDp32fVyGk1bOFWDNn526ZP3fc7bYlm/Oiwz8w==}
jscodeshift@0.15.2:
resolution: {integrity: sha512-FquR7Okgmc4Sd0aEDwqho3rEiKR3BdvuG9jfdHjLJ6JQoWSMpavug3AoIfnfWhxFlf+5pzQh8qjqz0DWFrNQzA==}
hasBin: true
@ -7828,6 +7865,11 @@ packages:
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
mocha@11.7.5:
resolution: {integrity: sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
hasBin: true
motion-dom@12.23.23:
resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==}
@ -7948,6 +7990,9 @@ packages:
node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-dir@0.1.17:
resolution: {integrity: sha512-tmPX422rYgofd4epzrNoOXiE8XFZYOcCq1vD7MAXCDO+O+zndlA2ztdKKMa+EeuBG5tHETpr4ml4RGgpqDCCAg==}
engines: {node: '>= 0.10.5'}
@ -8890,6 +8935,10 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
recast@0.23.11:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'}
@ -10397,6 +10446,9 @@ packages:
wordwrap@1.0.0:
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
workerpool@9.3.4:
resolution: {integrity: sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
@ -10511,6 +10563,10 @@ packages:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs-unparser@2.0.0:
resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==}
engines: {node: '>=10'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
@ -10648,7 +10704,7 @@ snapshots:
'@babel/types': 7.28.4
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@ -10700,7 +10756,7 @@ snapshots:
'@babel/core': 7.28.4
'@babel/helper-compilation-targets': 7.27.2
'@babel/helper-plugin-utils': 7.27.1
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
lodash.debounce: 4.0.8
resolve: 1.22.10
transitivePeerDependencies:
@ -11404,7 +11460,7 @@ snapshots:
'@babel/parser': 7.28.4
'@babel/template': 7.27.2
'@babel/types': 7.28.4
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@ -11884,7 +11940,7 @@ snapshots:
'@eslint/eslintrc@2.1.4':
dependencies:
ajv: 6.12.6
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
espree: 9.6.1
globals: 13.24.0
ignore: 5.3.2
@ -11957,7 +12013,7 @@ snapshots:
'@humanwhocodes/config-array@0.13.0':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
@ -11991,7 +12047,7 @@ snapshots:
'@istanbuljs/schema@0.1.3': {}
'@jest/environment-jsdom-abstract@30.2.0(jsdom@26.1.0)':
'@jest/environment-jsdom-abstract@30.2.0(canvas@3.2.0)(jsdom@26.1.0(canvas@3.2.0))':
dependencies:
'@jest/environment': 30.2.0
'@jest/fake-timers': 30.2.0
@ -12000,7 +12056,9 @@ snapshots:
'@types/node': 20.19.19
jest-mock: 30.2.0
jest-util: 30.2.0
jsdom: 26.1.0
jsdom: 26.1.0(canvas@3.2.0)
optionalDependencies:
canvas: 3.2.0
'@jest/environment@30.2.0':
dependencies:
@ -12302,14 +12360,14 @@ snapshots:
postcss-selector-parser: 6.1.2
ts-pattern: 5.0.5
'@pandacss/dev@0.20.1(jsdom@27.0.0(postcss@8.5.6))(typescript@5.9.3)':
'@pandacss/dev@0.20.1(jsdom@27.0.0(canvas@3.2.0)(postcss@8.5.6))(typescript@5.9.3)':
dependencies:
'@clack/prompts': 0.6.3
'@pandacss/config': 0.20.1
'@pandacss/error': 0.20.1
'@pandacss/logger': 0.20.1
'@pandacss/node': 0.20.1(jsdom@27.0.0(postcss@8.5.6))(typescript@5.9.3)
'@pandacss/postcss': 0.20.1(jsdom@27.0.0(postcss@8.5.6))(typescript@5.9.3)
'@pandacss/node': 0.20.1(jsdom@27.0.0(canvas@3.2.0)(postcss@8.5.6))(typescript@5.9.3)
'@pandacss/postcss': 0.20.1(jsdom@27.0.0(canvas@3.2.0)(postcss@8.5.6))(typescript@5.9.3)
'@pandacss/preset-panda': 0.20.1
'@pandacss/shared': 0.20.1
'@pandacss/token-dictionary': 0.20.1
@ -12323,9 +12381,9 @@ snapshots:
'@pandacss/error@0.20.1': {}
'@pandacss/extractor@0.20.1(jsdom@27.0.0(postcss@8.5.6))(typescript@5.9.3)':
'@pandacss/extractor@0.20.1(jsdom@27.0.0(canvas@3.2.0)(postcss@8.5.6))(typescript@5.9.3)':
dependencies:
ts-evaluator: 1.2.0(jsdom@27.0.0(postcss@8.5.6))(typescript@5.9.3)
ts-evaluator: 1.2.0(jsdom@27.0.0(canvas@3.2.0)(postcss@8.5.6))(typescript@5.9.3)
ts-morph: 19.0.0
transitivePeerDependencies:
- jsdom
@ -12353,16 +12411,16 @@ snapshots:
kleur: 4.1.5
lil-fp: 1.4.5
'@pandacss/node@0.20.1(jsdom@27.0.0(postcss@8.5.6))(typescript@5.9.3)':
'@pandacss/node@0.20.1(jsdom@27.0.0(canvas@3.2.0)(postcss@8.5.6))(typescript@5.9.3)':
dependencies:
'@pandacss/config': 0.20.1
'@pandacss/core': 0.20.1
'@pandacss/error': 0.20.1
'@pandacss/extractor': 0.20.1(jsdom@27.0.0(postcss@8.5.6))(typescript@5.9.3)
'@pandacss/extractor': 0.20.1(jsdom@27.0.0(canvas@3.2.0)(postcss@8.5.6))(typescript@5.9.3)
'@pandacss/generator': 0.20.1
'@pandacss/is-valid-prop': 0.20.1
'@pandacss/logger': 0.20.1
'@pandacss/parser': 0.20.1(jsdom@27.0.0(postcss@8.5.6))(typescript@5.9.3)
'@pandacss/parser': 0.20.1(jsdom@27.0.0(canvas@3.2.0)(postcss@8.5.6))(typescript@5.9.3)
'@pandacss/shared': 0.20.1
'@pandacss/token-dictionary': 0.20.1
'@pandacss/types': 0.20.1
@ -12392,10 +12450,10 @@ snapshots:
- jsdom
- typescript
'@pandacss/parser@0.20.1(jsdom@27.0.0(postcss@8.5.6))(typescript@5.9.3)':
'@pandacss/parser@0.20.1(jsdom@27.0.0(canvas@3.2.0)(postcss@8.5.6))(typescript@5.9.3)':
dependencies:
'@pandacss/config': 0.20.1
'@pandacss/extractor': 0.20.1(jsdom@27.0.0(postcss@8.5.6))(typescript@5.9.3)
'@pandacss/extractor': 0.20.1(jsdom@27.0.0(canvas@3.2.0)(postcss@8.5.6))(typescript@5.9.3)
'@pandacss/is-valid-prop': 0.20.1
'@pandacss/logger': 0.20.1
'@pandacss/shared': 0.20.1
@ -12409,9 +12467,9 @@ snapshots:
- jsdom
- typescript
'@pandacss/postcss@0.20.1(jsdom@27.0.0(postcss@8.5.6))(typescript@5.9.3)':
'@pandacss/postcss@0.20.1(jsdom@27.0.0(canvas@3.2.0)(postcss@8.5.6))(typescript@5.9.3)':
dependencies:
'@pandacss/node': 0.20.1(jsdom@27.0.0(postcss@8.5.6))(typescript@5.9.3)
'@pandacss/node': 0.20.1(jsdom@27.0.0(canvas@3.2.0)(postcss@8.5.6))(typescript@5.9.3)
postcss: 8.5.6
transitivePeerDependencies:
- jsdom
@ -13512,7 +13570,7 @@ snapshots:
conventional-changelog-angular: 7.0.0
conventional-commits-filter: 4.0.0
conventional-commits-parser: 5.0.0
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
import-from-esm: 1.3.4
lodash-es: 4.17.21
micromatch: 4.0.8
@ -13528,7 +13586,7 @@ snapshots:
dependencies:
'@semantic-release/error': 3.0.0
aggregate-error: 3.1.0
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
dir-glob: 3.0.1
execa: 5.1.1
lodash: 4.17.21
@ -13546,7 +13604,7 @@ snapshots:
'@octokit/plugin-throttling': 8.2.0(@octokit/core@5.2.2)
'@semantic-release/error': 4.0.0
aggregate-error: 5.0.0
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
dir-glob: 3.0.1
globby: 14.1.0
http-proxy-agent: 7.0.2
@ -13583,7 +13641,7 @@ snapshots:
conventional-changelog-writer: 7.0.1
conventional-commits-filter: 4.0.0
conventional-commits-parser: 5.0.0
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
get-stream: 7.0.1
import-from-esm: 1.3.4
into-stream: 7.0.0
@ -14226,7 +14284,7 @@ snapshots:
'@storybook/react-docgen-typescript-plugin@1.0.6--canary.9.0c3f3b7.0(typescript@5.9.3)(webpack@5.102.0(esbuild@0.27.2))':
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
endent: 2.1.0
find-cache-dir: 3.3.2
flat-cache: 3.2.0
@ -14987,7 +15045,7 @@ snapshots:
'@typescript-eslint/types': 8.46.0
'@typescript-eslint/typescript-estree': 8.46.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.46.0
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
eslint: 8.57.1
typescript: 5.9.3
transitivePeerDependencies:
@ -14997,7 +15055,7 @@ snapshots:
dependencies:
'@typescript-eslint/tsconfig-utils': 8.46.0(typescript@5.9.3)
'@typescript-eslint/types': 8.46.0
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@ -15016,7 +15074,7 @@ snapshots:
'@typescript-eslint/types': 8.46.0
'@typescript-eslint/typescript-estree': 8.46.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.46.0(eslint@8.57.1)(typescript@5.9.3)
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
eslint: 8.57.1
ts-api-utils: 2.1.0(typescript@5.9.3)
typescript: 5.9.3
@ -15031,7 +15089,7 @@ snapshots:
'@typescript-eslint/tsconfig-utils': 8.46.0(typescript@5.9.3)
'@typescript-eslint/types': 8.46.0
'@typescript-eslint/visitor-keys': 8.46.0
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
@ -15215,7 +15273,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 2.0.0
vitest: 1.6.1(@types/node@20.19.19)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jsdom@27.0.0(postcss@8.5.6))(terser@5.44.0)
vitest: 1.6.1(@types/node@20.19.19)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jsdom@27.0.0(canvas@3.2.0)(postcss@8.5.6))(terser@5.44.0)
'@vitest/utils@1.6.1':
dependencies:
@ -15755,6 +15813,8 @@ snapshots:
browser-assert@1.2.1: {}
browser-stdout@1.3.1: {}
browserify-aes@1.2.0:
dependencies:
buffer-xor: 1.0.3
@ -15877,6 +15937,8 @@ snapshots:
camelcase@5.3.1: {}
camelcase@6.3.0: {}
camera-controls@2.10.1(three@0.169.0):
dependencies:
three: 0.169.0
@ -15892,6 +15954,11 @@ snapshots:
canvas-confetti@1.9.4: {}
canvas@3.2.0:
dependencies:
node-addon-api: 7.1.1
prebuild-install: 7.1.3
cardinal@2.1.1:
dependencies:
ansicolors: 0.3.2
@ -15958,6 +16025,10 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
chord-voicings@0.0.1:
dependencies:
'@tonaljs/tonal': 4.10.0
@ -16366,12 +16437,16 @@ snapshots:
dependencies:
ms: 2.1.3
debug@4.4.3:
debug@4.4.3(supports-color@8.1.1):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 8.1.1
decamelize@1.2.0: {}
decamelize@4.0.0: {}
decimal.js@10.6.0: {}
decode-formdata@0.4.0: {}
@ -16487,7 +16562,7 @@ snapshots:
detect-port@1.6.1:
dependencies:
address: 1.2.2
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@ -16497,6 +16572,8 @@ snapshots:
diff-sequences@29.6.3: {}
diff@7.0.0: {}
diffie-hellman@5.0.3:
dependencies:
bn.js: 4.12.2
@ -16850,14 +16927,14 @@ snapshots:
esbuild-register@3.6.0(esbuild@0.18.20):
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
esbuild: 0.18.20
transitivePeerDependencies:
- supports-color
esbuild-register@3.6.0(esbuild@0.25.10):
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
esbuild: 0.25.10
transitivePeerDependencies:
- supports-color
@ -17050,7 +17127,7 @@ snapshots:
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
eslint: 8.57.1
get-tsconfig: 4.11.0
is-bun-module: 2.0.0
@ -17183,7 +17260,7 @@ snapshots:
ajv: 6.12.6
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
doctrine: 3.0.0
escape-string-regexp: 4.0.0
eslint-scope: 7.2.2
@ -17479,6 +17556,8 @@ snapshots:
keyv: 4.5.4
rimraf: 3.0.2
flat@5.0.2: {}
flatted@3.3.3: {}
flow-parser@0.287.0: {}
@ -17924,7 +18003,7 @@ snapshots:
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@ -17933,14 +18012,14 @@ snapshots:
https-proxy-agent@4.0.0:
dependencies:
agent-base: 5.1.1
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@ -17977,7 +18056,7 @@ snapshots:
import-from-esm@1.3.4:
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
import-meta-resolve: 4.2.0
transitivePeerDependencies:
- supports-color
@ -18130,6 +18209,8 @@ snapshots:
is-path-inside@3.0.3: {}
is-plain-obj@2.1.0: {}
is-plain-obj@4.1.0: {}
is-plain-object@2.0.4:
@ -18270,13 +18351,15 @@ snapshots:
jazz-midi@1.7.9:
optional: true
jest-environment-jsdom@30.2.0:
jest-environment-jsdom@30.2.0(canvas@3.2.0):
dependencies:
'@jest/environment': 30.2.0
'@jest/environment-jsdom-abstract': 30.2.0(jsdom@26.1.0)
'@jest/environment-jsdom-abstract': 30.2.0(canvas@3.2.0)(jsdom@26.1.0(canvas@3.2.0))
'@types/jsdom': 21.1.7
'@types/node': 20.19.19
jsdom: 26.1.0
jsdom: 26.1.0(canvas@3.2.0)
optionalDependencies:
canvas: 3.2.0
transitivePeerDependencies:
- bufferutil
- supports-color
@ -18377,6 +18460,16 @@ snapshots:
dependencies:
argparse: 2.0.1
jscanify@1.4.0:
dependencies:
canvas: 3.2.0
jsdom: 26.1.0(canvas@3.2.0)
mocha: 11.7.5
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
jscodeshift@0.15.2(@babel/preset-env@7.28.3(@babel/core@7.28.4)):
dependencies:
'@babel/core': 7.28.4
@ -18404,7 +18497,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
jsdom@26.1.0:
jsdom@26.1.0(canvas@3.2.0):
dependencies:
cssstyle: 4.6.0
data-urls: 5.0.0
@ -18426,12 +18519,14 @@ snapshots:
whatwg-url: 14.2.0
ws: 8.18.3
xml-name-validator: 5.0.0
optionalDependencies:
canvas: 3.2.0
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
jsdom@27.0.0(postcss@8.5.6):
jsdom@27.0.0(canvas@3.2.0)(postcss@8.5.6):
dependencies:
'@asamuzakjp/dom-selector': 6.6.1
cssstyle: 5.3.1(postcss@8.5.6)
@ -18453,6 +18548,8 @@ snapshots:
whatwg-url: 15.1.0
ws: 8.18.3
xml-name-validator: 5.0.0
optionalDependencies:
canvas: 3.2.0
transitivePeerDependencies:
- bufferutil
- postcss
@ -19048,7 +19145,7 @@ snapshots:
micromark@4.0.2:
dependencies:
'@types/debug': 4.1.12
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
decode-named-character-reference: 1.2.0
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
@ -19155,6 +19252,30 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.1
mocha@11.7.5:
dependencies:
browser-stdout: 1.3.1
chokidar: 4.0.3
debug: 4.4.3(supports-color@8.1.1)
diff: 7.0.0
escape-string-regexp: 4.0.0
find-up: 5.0.0
glob: 10.4.5
he: 1.2.0
is-path-inside: 3.0.3
js-yaml: 4.1.0
log-symbols: 4.1.0
minimatch: 9.0.5
ms: 2.1.3
picocolors: 1.1.1
serialize-javascript: 6.0.2
strip-json-comments: 3.1.1
supports-color: 8.1.1
workerpool: 9.3.4
yargs: 17.7.2
yargs-parser: 21.1.1
yargs-unparser: 2.0.0
motion-dom@12.23.23:
dependencies:
motion-utils: 12.23.6
@ -19250,6 +19371,8 @@ snapshots:
node-abort-controller@3.1.1: {}
node-addon-api@7.1.1: {}
node-dir@0.1.17:
dependencies:
minimatch: 3.1.2
@ -19917,7 +20040,7 @@ snapshots:
puppeteer-core@2.1.1:
dependencies:
'@types/mime-types': 2.1.4
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
extract-zip: 1.7.0
https-proxy-agent: 4.0.0
mime: 2.6.0
@ -20172,6 +20295,8 @@ snapshots:
dependencies:
picomatch: 2.3.1
readdirp@4.1.2: {}
recast@0.23.11:
dependencies:
ast-types: 0.16.1
@ -20501,7 +20626,7 @@ snapshots:
'@semantic-release/release-notes-generator': 12.1.0(semantic-release@22.0.12(typescript@5.9.3))
aggregate-error: 5.0.0
cosmiconfig: 8.3.6(typescript@5.9.3)
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
env-ci: 10.0.0
execa: 8.0.1
figures: 6.1.0
@ -21241,14 +21366,14 @@ snapshots:
ts-dedent@2.2.0: {}
ts-evaluator@1.2.0(jsdom@27.0.0(postcss@8.5.6))(typescript@5.9.3):
ts-evaluator@1.2.0(jsdom@27.0.0(canvas@3.2.0)(postcss@8.5.6))(typescript@5.9.3):
dependencies:
ansi-colors: 4.1.3
crosspath: 2.0.0
object-path: 0.11.8
typescript: 5.9.3
optionalDependencies:
jsdom: 27.0.0(postcss@8.5.6)
jsdom: 27.0.0(canvas@3.2.0)(postcss@8.5.6)
ts-interface-checker@0.1.13: {}
@ -21304,7 +21429,7 @@ snapshots:
bundle-require: 4.2.1(esbuild@0.19.12)
cac: 6.7.14
chokidar: 3.6.0
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
esbuild: 0.19.12
execa: 5.1.1
globby: 11.1.0
@ -21650,7 +21775,7 @@ snapshots:
vite-node@1.6.1(@types/node@20.19.19)(terser@5.44.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
pathe: 1.1.2
picocolors: 1.1.1
vite: 5.4.20(@types/node@20.19.19)(terser@5.44.0)
@ -21685,7 +21810,7 @@ snapshots:
fsevents: 2.3.3
terser: 5.44.0
vitest@1.6.1(@types/node@20.19.19)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jsdom@27.0.0(postcss@8.5.6))(terser@5.44.0):
vitest@1.6.1(@types/node@20.19.19)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jsdom@27.0.0(canvas@3.2.0)(postcss@8.5.6))(terser@5.44.0):
dependencies:
'@vitest/expect': 1.6.1
'@vitest/runner': 1.6.1
@ -21694,7 +21819,7 @@ snapshots:
'@vitest/utils': 1.6.1
acorn-walk: 8.3.4
chai: 4.5.0
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
execa: 8.0.1
local-pkg: 0.5.1
magic-string: 0.30.19
@ -21711,7 +21836,7 @@ snapshots:
'@types/node': 20.19.19
'@vitest/ui': 3.2.4(vitest@1.6.1)
happy-dom: 18.0.1
jsdom: 27.0.0(postcss@8.5.6)
jsdom: 27.0.0(canvas@3.2.0)(postcss@8.5.6)
transitivePeerDependencies:
- less
- lightningcss
@ -21901,6 +22026,8 @@ snapshots:
wordwrap@1.0.0: {}
workerpool@9.3.4: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
@ -21978,6 +22105,13 @@ snapshots:
yargs-parser@21.1.1: {}
yargs-unparser@2.0.0:
dependencies:
camelcase: 6.3.0
decamelize: 4.0.0
flat: 5.0.2
is-plain-obj: 2.1.0
yargs@15.4.1:
dependencies:
cliui: 6.0.0