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:
parent
9124a3182e
commit
5f4f1fde33
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
256
pnpm-lock.yaml
256
pnpm-lock.yaml
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue