ltmarx / web /src /components /DetectPanel.tsx
harelcain's picture
Upload 16 files
44f463d verified
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>
);
}