Spaces:
Running
Running
| import { useState, useRef, useCallback } from 'react'; | |
| import type { DetectionResult } from '@core/types.js'; | |
| import { autoDetectMultiFrame, type AutoDetectResult } from '@core/detector.js'; | |
| import { extractFrames, rgbaToY } from '../lib/video-io.js'; | |
| import ResultCard from './ResultCard.js'; | |
| export default function DetectPanel() { | |
| const [videoUrl, setVideoUrl] = useState<string | null>(null); | |
| const [videoName, setVideoName] = useState(''); | |
| const [key, setKey] = useState(''); | |
| const [maxFrames, setMaxFrames] = useState(10); | |
| const [cropResilient, setCropResilient] = useState(false); | |
| const [processing, setProcessing] = useState(false); | |
| const [progress, setProgress] = useState({ phase: '', current: 0, total: 0 }); | |
| const [result, setResult] = useState<AutoDetectResult | null>(null); | |
| const fileRef = useRef<HTMLInputElement>(null); | |
| const handleFile = useCallback((file: File) => { | |
| const url = URL.createObjectURL(file); | |
| setVideoUrl(url); | |
| setVideoName(file.name); | |
| setResult(null); | |
| }, []); | |
| const handleDrop = useCallback( | |
| (e: React.DragEvent) => { | |
| e.preventDefault(); | |
| const file = e.dataTransfer.files[0]; | |
| if (file?.type.startsWith('video/')) handleFile(file); | |
| }, | |
| [handleFile] | |
| ); | |
| const handleDetect = async () => { | |
| if (!videoUrl || !key) return; | |
| setProcessing(true); | |
| setResult(null); | |
| try { | |
| setProgress({ phase: 'Extracting frames', current: 0, total: 0 }); | |
| const { frames, width, height } = await extractFrames(videoUrl, maxFrames, (c, t) => | |
| setProgress({ phase: 'Extracting frames', current: c, total: t }) | |
| ); | |
| setProgress({ phase: 'Converting frames', current: 0, total: frames.length }); | |
| const yPlanes = frames.map((frame, i) => { | |
| setProgress({ phase: 'Converting frames', current: i + 1, total: frames.length }); | |
| return rgbaToY(frame); | |
| }); | |
| setProgress({ phase: 'Trying all presets', current: 0, total: 0 }); | |
| const detection = autoDetectMultiFrame(yPlanes, width, height, key, { cropResilient }); | |
| setResult(detection); | |
| } catch (e) { | |
| console.error('Detection error:', e); | |
| alert(`Error: ${e}`); | |
| } finally { | |
| setProcessing(false); | |
| } | |
| }; | |
| return ( | |
| <div className="space-y-8"> | |
| {/* Upload area */} | |
| <div | |
| onDrop={handleDrop} | |
| onDragOver={(e) => e.preventDefault()} | |
| onClick={() => fileRef.current?.click()} | |
| className={`group cursor-pointer rounded-xl border-2 border-dashed p-10 text-center transition-colors | |
| ${videoUrl | |
| ? 'border-zinc-700 bg-zinc-900/30' | |
| : 'border-zinc-800 bg-zinc-900/20 hover:border-zinc-600 hover:bg-zinc-900/40' | |
| }`} | |
| > | |
| <input | |
| ref={fileRef} | |
| type="file" | |
| accept="video/*" | |
| className="hidden" | |
| onChange={(e) => { | |
| const file = e.target.files?.[0]; | |
| if (file) handleFile(file); | |
| }} | |
| /> | |
| {videoUrl ? ( | |
| <div className="space-y-2"> | |
| <p className="text-sm font-medium text-zinc-300">{videoName}</p> | |
| <p className="text-xs text-zinc-500">Click or drop to replace</p> | |
| </div> | |
| ) : ( | |
| <div className="space-y-2"> | |
| <svg className="mx-auto h-8 w-8 text-zinc-600 transition-colors group-hover:text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" /> | |
| </svg> | |
| <p className="text-sm text-zinc-400">Drop a video file to analyze</p> | |
| <p className="text-xs text-zinc-600">Upload a potentially watermarked video</p> | |
| </div> | |
| )} | |
| </div> | |
| {/* Configuration — just key and frame count */} | |
| <div className="grid gap-6 sm:grid-cols-2"> | |
| <div className="space-y-1.5"> | |
| <label className="text-sm font-medium text-zinc-300">Secret Key</label> | |
| <input | |
| type="text" | |
| value={key} | |
| onChange={(e) => setKey(e.target.value)} | |
| placeholder="Enter the secret key used for embedding..." | |
| className="w-full rounded-lg border border-zinc-800 bg-zinc-900/50 px-3 py-2 text-sm text-zinc-100 | |
| placeholder:text-zinc-600 focus:border-blue-600 focus:outline-none focus:ring-1 focus:ring-blue-600" | |
| /> | |
| </div> | |
| <div className="space-y-1.5"> | |
| <label className="text-sm font-medium text-zinc-300">Frames to analyze</label> | |
| <input | |
| type="number" | |
| value={maxFrames} | |
| onChange={(e) => setMaxFrames(Math.max(1, Math.min(100, parseInt(e.target.value) || 10)))} | |
| min={1} | |
| max={100} | |
| className="w-full rounded-lg border border-zinc-800 bg-zinc-900/50 px-3 py-2 text-sm text-zinc-100 | |
| focus:border-blue-600 focus:outline-none focus:ring-1 focus:ring-blue-600" | |
| /> | |
| <p className="text-[10px] text-zinc-600">More frames = better detection, slower processing</p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <label className="relative inline-flex cursor-pointer items-center"> | |
| <input | |
| type="checkbox" | |
| checked={cropResilient} | |
| onChange={(e) => setCropResilient(e.target.checked)} | |
| className="peer sr-only" | |
| /> | |
| <div className="h-5 w-9 rounded-full bg-zinc-700 after:absolute after:left-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:bg-zinc-400 after:transition-all peer-checked:bg-violet-600 peer-checked:after:translate-x-full peer-checked:after:bg-white" /> | |
| </label> | |
| <div> | |
| <span className="text-sm text-zinc-300">Crop-resilient detection</span> | |
| <p className="text-[10px] text-zinc-600">Slower — brute-forces DWT alignment for cropped videos</p> | |
| </div> | |
| </div> | |
| <p className="text-xs text-zinc-500"> | |
| All presets will be tried automatically. No need to know which preset was used during embedding. | |
| </p> | |
| {/* Detect button */} | |
| <button | |
| onClick={handleDetect} | |
| disabled={!videoUrl || !key || processing} | |
| className="w-full rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white | |
| transition-colors hover:bg-violet-500 disabled:cursor-not-allowed disabled:bg-zinc-800 disabled:text-zinc-500" | |
| > | |
| {processing ? ( | |
| <span className="flex items-center justify-center gap-2"> | |
| <span className="h-4 w-4 animate-spin rounded-full border-2 border-zinc-400 border-t-white" /> | |
| {progress.phase} {progress.total > 0 ? `${progress.current}/${progress.total}` : ''} | |
| </span> | |
| ) : ( | |
| 'Detect Watermark' | |
| )} | |
| </button> | |
| {/* Results */} | |
| <ResultCard result={result} presetUsed={result?.presetUsed ?? null} loading={processing} /> | |
| </div> | |
| ); | |
| } | |