166 lines
6.8 KiB
TypeScript
166 lines
6.8 KiB
TypeScript
|
||
import React, { useRef, useState, useEffect } from 'react';
|
||
import { extractBookData } from '../services/geminiService';
|
||
import { Book } from '../types';
|
||
|
||
interface ScannerProps {
|
||
onCancel: () => void;
|
||
onDetected: (data: Partial<Book>) => void;
|
||
}
|
||
|
||
export const Scanner: React.FC<ScannerProps> = ({ onCancel, onDetected }) => {
|
||
const videoRef = useRef<HTMLVideoElement>(null);
|
||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||
const [isProcessing, setIsProcessing] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
let stream: MediaStream | null = null;
|
||
|
||
async function startCamera() {
|
||
try {
|
||
// Try preferred environment camera first
|
||
try {
|
||
stream = await navigator.mediaDevices.getUserMedia({
|
||
video: { facingMode: 'environment' }
|
||
});
|
||
} catch (innerErr) {
|
||
console.warn("Environment camera not found, falling back to default camera...");
|
||
// Fallback to any available video source
|
||
stream = await navigator.mediaDevices.getUserMedia({
|
||
video: true
|
||
});
|
||
}
|
||
|
||
if (videoRef.current) {
|
||
videoRef.current.srcObject = stream;
|
||
}
|
||
} catch (err) {
|
||
setError('Не удалось получить доступ к камере. Убедитесь, что разрешения предоставлены.');
|
||
console.error("Camera access error:", err);
|
||
}
|
||
}
|
||
|
||
startCamera();
|
||
|
||
return () => {
|
||
if (stream) {
|
||
stream.getTracks().forEach(track => track.stop());
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
const handleCapture = async () => {
|
||
if (!videoRef.current || !canvasRef.current || isProcessing) return;
|
||
|
||
// Ensure the video is actually playing and has dimensions
|
||
if (videoRef.current.readyState < 2 || videoRef.current.videoWidth === 0) {
|
||
setError('Камера еще не готова. Пожалуйста, подождите.');
|
||
return;
|
||
}
|
||
|
||
setIsProcessing(true);
|
||
setError(null);
|
||
|
||
const canvas = canvasRef.current;
|
||
const video = videoRef.current;
|
||
|
||
// Scale down large images to avoid API limits/errors while keeping readability
|
||
const MAX_WIDTH = 1024;
|
||
const scale = Math.min(1, MAX_WIDTH / video.videoWidth);
|
||
canvas.width = video.videoWidth * scale;
|
||
canvas.height = video.videoHeight * scale;
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
if (ctx) {
|
||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||
// Use standard quality for reliable extraction
|
||
const base64Image = canvas.toDataURL('image/jpeg', 0.85).split(',')[1];
|
||
|
||
try {
|
||
const bookData = await extractBookData(base64Image);
|
||
onDetected({
|
||
...bookData,
|
||
coverUrl: canvas.toDataURL('image/jpeg') // Local preview for the form
|
||
});
|
||
} catch (err: any) {
|
||
console.error("OCR Error:", err);
|
||
setError('Не удалось распознать книгу. Попробуйте еще раз с лучшим освещением.');
|
||
setIsProcessing(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="relative h-screen w-full overflow-hidden bg-black flex flex-col">
|
||
<video
|
||
ref={videoRef}
|
||
autoPlay
|
||
playsInline
|
||
muted
|
||
className="absolute inset-0 w-full h-full object-cover opacity-80"
|
||
/>
|
||
<canvas ref={canvasRef} className="hidden" />
|
||
|
||
<div className="absolute inset-0 flex flex-col justify-between z-10">
|
||
<div className="flex items-center justify-between p-4 pt-12 bg-gradient-to-b from-black/70 to-transparent">
|
||
<button onClick={onCancel} className="flex items-center justify-center w-10 h-10 rounded-full bg-black/40 text-white backdrop-blur-md">
|
||
<span className="material-symbols-outlined text-2xl">close</span>
|
||
</button>
|
||
<div className="px-4 py-1.5 rounded-full bg-black/40 backdrop-blur-md border border-white/10">
|
||
<span className="text-white text-xs font-medium tracking-wide">СКАНЕР</span>
|
||
</div>
|
||
<div className="w-10"></div>
|
||
</div>
|
||
|
||
<div className="flex-1 flex flex-col items-center justify-center relative w-full">
|
||
<div className="relative w-[75%] aspect-[2/3] rounded-2xl border border-white/20 shadow-2xl">
|
||
<div className="absolute inset-0 rounded-2xl shadow-[0_0_0_9999px_rgba(0,0,0,0.65)] pointer-events-none"></div>
|
||
|
||
<div className="absolute -top-1 -left-1 w-8 h-8 border-t-4 border-l-4 border-primary rounded-tl-xl z-20"></div>
|
||
<div className="absolute -top-1 -right-1 w-8 h-8 border-t-4 border-r-4 border-primary rounded-tr-xl z-20"></div>
|
||
<div className="absolute -bottom-1 -left-1 w-8 h-8 border-b-4 border-l-4 border-primary rounded-bl-xl z-20"></div>
|
||
<div className="absolute -bottom-1 -right-1 w-8 h-8 border-b-4 border-r-4 border-primary rounded-br-xl z-20"></div>
|
||
|
||
<div className="absolute inset-0 overflow-hidden rounded-2xl z-10">
|
||
<div className="w-full h-1/2 bg-gradient-to-b from-transparent via-primary/30 to-transparent opacity-50 absolute top-0 animate-scan"></div>
|
||
<div className="w-full h-0.5 bg-primary shadow-[0_0_15px_rgba(23,207,84,0.8)] absolute top-1/2 animate-scan"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-8 text-center px-8">
|
||
<p className="text-white font-medium drop-shadow-md">
|
||
Поместите обложку в рамку
|
||
</p>
|
||
{isProcessing && (
|
||
<p className="text-primary text-sm font-medium mt-2 animate-pulse">
|
||
Распознавание...
|
||
</p>
|
||
)}
|
||
{error && (
|
||
<p className="text-red-400 text-sm font-medium mt-2 bg-black/50 p-2 rounded-lg backdrop-blur-sm">
|
||
{error}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="w-full bg-gradient-to-t from-black/90 via-black/80 to-transparent pb-10 pt-12 px-6">
|
||
<div className="flex items-center justify-center max-w-md mx-auto">
|
||
<button
|
||
disabled={isProcessing}
|
||
onClick={handleCapture}
|
||
className="relative group cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
<div className={`w-20 h-20 rounded-full border-4 ${isProcessing ? 'border-primary' : 'border-white/30'} flex items-center justify-center transition-all duration-300`}>
|
||
<div className={`w-16 h-16 ${isProcessing ? 'bg-primary animate-pulse' : 'bg-white'} rounded-full transition-all duration-200 group-active:scale-90 shadow-[0_0_20px_rgba(255,255,255,0.3)]`}></div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|