Spaces:
Running
Running
| import { useRef, useEffect, useState } from 'react'; | |
| interface ComparisonViewProps { | |
| originalFrames: ImageData[]; | |
| watermarkedFrames: ImageData[]; | |
| width: number; | |
| height: number; | |
| fps: number; | |
| } | |
| export default function ComparisonView({ | |
| originalFrames, | |
| watermarkedFrames, | |
| width, | |
| height, | |
| fps, | |
| }: ComparisonViewProps) { | |
| const [mode, setMode] = useState<'side-by-side' | 'difference'>('side-by-side'); | |
| const [amplify, setAmplify] = useState(1); | |
| const [playing, setPlaying] = useState(false); | |
| const [currentFrame, setCurrentFrame] = useState(0); | |
| const diffCanvasRef = useRef<HTMLCanvasElement>(null); | |
| const origCanvasRef = useRef<HTMLCanvasElement>(null); | |
| const wmCanvasRef = useRef<HTMLCanvasElement>(null); | |
| const animRef = useRef<number>(0); | |
| const lastFrameTime = useRef(0); | |
| const totalFrames = originalFrames.length; | |
| // Render side-by-side canvases for current frame | |
| useEffect(() => { | |
| if (mode !== 'side-by-side') return; | |
| const origCtx = origCanvasRef.current?.getContext('2d'); | |
| const wmCtx = wmCanvasRef.current?.getContext('2d'); | |
| if (origCtx && originalFrames[currentFrame]) { | |
| origCtx.putImageData(originalFrames[currentFrame], 0, 0); | |
| } | |
| if (wmCtx && watermarkedFrames[currentFrame]) { | |
| wmCtx.putImageData(watermarkedFrames[currentFrame], 0, 0); | |
| } | |
| }, [originalFrames, watermarkedFrames, mode, currentFrame]); | |
| // Render diff frame | |
| useEffect(() => { | |
| if (mode !== 'difference') return; | |
| const ctx = diffCanvasRef.current?.getContext('2d'); | |
| if (!ctx || !originalFrames[currentFrame] || !watermarkedFrames[currentFrame]) return; | |
| const orig = originalFrames[currentFrame]; | |
| const wm = watermarkedFrames[currentFrame]; | |
| const diff = new ImageData(width, height); | |
| for (let i = 0; i < orig.data.length; i += 4) { | |
| const dr = Math.abs(orig.data[i] - wm.data[i]); | |
| const dg = Math.abs(orig.data[i + 1] - wm.data[i + 1]); | |
| const db = Math.abs(orig.data[i + 2] - wm.data[i + 2]); | |
| const d = Math.max(dr, dg, db); | |
| const amplified = Math.min(255, d * amplify); | |
| diff.data[i] = amplified; | |
| diff.data[i + 1] = 0; | |
| diff.data[i + 2] = 255 - amplified; | |
| diff.data[i + 3] = 255; | |
| } | |
| ctx.putImageData(diff, 0, 0); | |
| }, [originalFrames, watermarkedFrames, mode, amplify, currentFrame, width, height]); | |
| // Playback loop | |
| useEffect(() => { | |
| if (!playing) return; | |
| const interval = 1000 / Math.min(fps, 5); | |
| const step = (time: number) => { | |
| if (time - lastFrameTime.current >= interval) { | |
| setCurrentFrame((f) => (f + 1) % totalFrames); | |
| lastFrameTime.current = time; | |
| } | |
| animRef.current = requestAnimationFrame(step); | |
| }; | |
| animRef.current = requestAnimationFrame(step); | |
| return () => cancelAnimationFrame(animRef.current); | |
| }, [playing, fps, totalFrames]); | |
| const aspect = `${width} / ${height}`; | |
| return ( | |
| <div className="space-y-3"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <div className="flex gap-1 rounded-md bg-zinc-800/50 p-0.5"> | |
| <button | |
| onClick={() => setMode('side-by-side')} | |
| className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${ | |
| mode === 'side-by-side' ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200' | |
| }`} | |
| > | |
| Side by Side | |
| </button> | |
| <button | |
| onClick={() => setMode('difference')} | |
| className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${ | |
| mode === 'difference' ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200' | |
| }`} | |
| > | |
| Difference | |
| </button> | |
| </div> | |
| <button | |
| onClick={() => setPlaying(!playing)} | |
| className="rounded-md bg-zinc-800/50 px-2.5 py-1 text-xs text-zinc-400 hover:text-zinc-200 transition-colors" | |
| > | |
| {playing ? 'Pause' : 'Play'} | |
| </button> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| {mode === 'difference' && ( | |
| <div className="flex items-center gap-2"> | |
| <span className="text-[10px] text-zinc-500">Amplify</span> | |
| <input | |
| type="range" | |
| min={1} | |
| max={10} | |
| value={amplify} | |
| onChange={(e) => setAmplify(parseInt(e.target.value))} | |
| className="h-1 w-20 appearance-none rounded-full bg-zinc-700 accent-violet-500 | |
| [&::-webkit-slider-thumb]:appearance-none | |
| [&::-webkit-slider-thumb]:w-3 | |
| [&::-webkit-slider-thumb]:h-3 | |
| [&::-webkit-slider-thumb]:rounded-full | |
| [&::-webkit-slider-thumb]:bg-violet-500" | |
| /> | |
| <span className="text-[10px] tabular-nums text-zinc-500">{amplify}x</span> | |
| </div> | |
| )} | |
| <span className="text-[10px] tabular-nums text-zinc-600"> | |
| {currentFrame + 1}/{totalFrames} | |
| </span> | |
| </div> | |
| </div> | |
| {mode === 'side-by-side' ? ( | |
| <div className="grid grid-cols-2 gap-2"> | |
| <div className="space-y-1"> | |
| <p className="text-[10px] uppercase tracking-wider text-zinc-600">Original</p> | |
| <canvas | |
| ref={origCanvasRef} | |
| width={width} | |
| height={height} | |
| className="w-full rounded-lg border border-zinc-800" | |
| style={{ aspectRatio: aspect }} | |
| /> | |
| </div> | |
| <div className="space-y-1"> | |
| <p className="text-[10px] uppercase tracking-wider text-zinc-600">Watermarked</p> | |
| <canvas | |
| ref={wmCanvasRef} | |
| width={width} | |
| height={height} | |
| className="w-full rounded-lg border border-zinc-800" | |
| style={{ aspectRatio: aspect }} | |
| /> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="space-y-1"> | |
| <p className="text-[10px] uppercase tracking-wider text-zinc-600"> | |
| Pixel Difference (amplified {amplify}x) | |
| </p> | |
| <canvas | |
| ref={diffCanvasRef} | |
| width={width} | |
| height={height} | |
| className="w-full rounded-lg border border-zinc-800" | |
| style={{ aspectRatio: aspect }} | |
| /> | |
| </div> | |
| )} | |
| {/* Frame scrubber */} | |
| <input | |
| type="range" | |
| min={0} | |
| max={totalFrames - 1} | |
| value={currentFrame} | |
| onChange={(e) => { setCurrentFrame(parseInt(e.target.value)); setPlaying(false); }} | |
| className="w-full h-1 appearance-none rounded-full bg-zinc-700 accent-blue-500 | |
| [&::-webkit-slider-thumb]:appearance-none | |
| [&::-webkit-slider-thumb]:w-3 | |
| [&::-webkit-slider-thumb]:h-3 | |
| [&::-webkit-slider-thumb]:rounded-full | |
| [&::-webkit-slider-thumb]:bg-blue-500" | |
| /> | |
| </div> | |
| ); | |
| } | |