Spaces:
Running
Running
| import { useState, useCallback } from 'react'; | |
| import { autoDetectMultiFrame, type DetectOptions } from '@core/detector.js'; | |
| import { attackReencode, attackDownscale, attackBrightness, attackContrast, attackSaturation, attackCrop } from '../lib/video-io.js'; | |
| type TestStatus = 'idle' | 'running' | 'pass' | 'fail' | 'error'; | |
| interface TestResult { | |
| status: TestStatus; | |
| confidence?: number; | |
| payloadMatch?: boolean; | |
| } | |
| interface RobustnessTestProps { | |
| blob: Blob; | |
| width: number; | |
| height: number; | |
| payload: string; | |
| secretKey: string; | |
| } | |
| interface TestDef { | |
| label: string; | |
| category: string; | |
| run: (blob: Blob, w: number, h: number) => Promise<{ yPlanes: Uint8Array[]; width: number; height: number }>; | |
| detectOptions?: DetectOptions; | |
| } | |
| const TESTS: TestDef[] = [ | |
| // CRF re-encoding | |
| { label: 'CRF 23', category: 'Re-encode', run: (b, w, h) => attackReencode(b, 23, w, h) }, | |
| { label: 'CRF 28', category: 'Re-encode', run: (b, w, h) => attackReencode(b, 28, w, h) }, | |
| { label: 'CRF 33', category: 'Re-encode', run: (b, w, h) => attackReencode(b, 33, w, h) }, | |
| { label: 'CRF 38', category: 'Re-encode', run: (b, w, h) => attackReencode(b, 38, w, h) }, | |
| { label: 'CRF 43', category: 'Re-encode', run: (b, w, h) => attackReencode(b, 43, w, h) }, | |
| // Downscale | |
| { label: '25%', category: 'Downscale', run: (b, w, h) => attackDownscale(b, 25, w, h) }, | |
| { label: '50%', category: 'Downscale', run: (b, w, h) => attackDownscale(b, 50, w, h) }, | |
| { label: '75%', category: 'Downscale', run: (b, w, h) => attackDownscale(b, 75, w, h) }, | |
| { label: '90%', category: 'Downscale', run: (b, w, h) => attackDownscale(b, 90, w, h) }, | |
| // Brightness | |
| { label: '-0.2', category: 'Brightness', run: (b, w, h) => attackBrightness(b, -0.2, w, h) }, | |
| { label: '+0.2', category: 'Brightness', run: (b, w, h) => attackBrightness(b, 0.2, w, h) }, | |
| { label: '+0.4', category: 'Brightness', run: (b, w, h) => attackBrightness(b, 0.4, w, h) }, | |
| // Contrast | |
| { label: '0.5x', category: 'Contrast', run: (b, w, h) => attackContrast(b, 0.5, w, h) }, | |
| { label: '1.5x', category: 'Contrast', run: (b, w, h) => attackContrast(b, 1.5, w, h) }, | |
| { label: '2.0x', category: 'Contrast', run: (b, w, h) => attackContrast(b, 2.0, w, h) }, | |
| // Saturation | |
| { label: '0x', category: 'Saturation', run: (b, w, h) => attackSaturation(b, 0, w, h) }, | |
| { label: '0.5x', category: 'Saturation', run: (b, w, h) => attackSaturation(b, 0.5, w, h) }, | |
| { label: '2.0x', category: 'Saturation', run: (b, w, h) => attackSaturation(b, 2.0, w, h) }, | |
| // Crop (~5-10% from edges) | |
| { label: '5%', category: 'Crop', run: (b, w, h) => { | |
| const px = Math.round(Math.min(w, h) * 0.05); | |
| return attackCrop(b, px, px, px, px, w, h); | |
| }, detectOptions: { cropResilient: true } }, | |
| { label: '10%', category: 'Crop', run: (b, w, h) => { | |
| const px = Math.round(Math.min(w, h) * 0.10); | |
| return attackCrop(b, px, px, px, px, w, h); | |
| }, detectOptions: { cropResilient: true } }, | |
| { label: '15%', category: 'Crop', run: (b, w, h) => { | |
| const px = Math.round(Math.min(w, h) * 0.15); | |
| return attackCrop(b, px, px, px, px, w, h); | |
| }, detectOptions: { cropResilient: true } }, | |
| { label: '20%', category: 'Crop', run: (b, w, h) => { | |
| const px = Math.round(Math.min(w, h) * 0.20); | |
| return attackCrop(b, px, px, px, px, w, h); | |
| }, detectOptions: { cropResilient: true } }, | |
| ]; | |
| function payloadToHex(payload: Uint8Array): string { | |
| return Array.from(payload).map((b) => b.toString(16).padStart(2, '0')).join('').toUpperCase(); | |
| } | |
| export default function RobustnessTest({ blob, width, height, payload, secretKey }: RobustnessTestProps) { | |
| const [results, setResults] = useState<Record<number, TestResult>>({}); | |
| const [runningAll, setRunningAll] = useState(false); | |
| const [progress, setProgress] = useState(0); | |
| const expectedHex = payload.replace(/^0x/, '').toUpperCase(); | |
| const runTest = useCallback(async (idx: number) => { | |
| setResults((prev) => ({ ...prev, [idx]: { status: 'running' } })); | |
| try { | |
| const test = TESTS[idx]; | |
| const attacked = await test.run(blob, width, height); | |
| // Pipeline already caps at 30 frames; use up to 10 evenly spaced | |
| const step = Math.max(1, Math.floor(attacked.yPlanes.length / 10)); | |
| const sampled = attacked.yPlanes.filter((_, i) => i % step === 0).slice(0, 10); | |
| const detection = autoDetectMultiFrame(sampled, attacked.width, attacked.height, secretKey, test.detectOptions); | |
| const detectedHex = detection.payload ? payloadToHex(detection.payload) : ''; | |
| const match = detection.detected && detectedHex === expectedHex; | |
| setResults((prev) => ({ | |
| ...prev, | |
| [idx]: { | |
| status: match ? 'pass' : 'fail', | |
| confidence: detection.confidence, | |
| payloadMatch: match, | |
| }, | |
| })); | |
| } catch (e) { | |
| console.error(`Robustness test ${idx} error:`, e); | |
| setResults((prev) => ({ ...prev, [idx]: { status: 'error' } })); | |
| } | |
| }, [blob, width, height, secretKey, expectedHex]); | |
| const runAll = useCallback(async () => { | |
| setRunningAll(true); | |
| setProgress(0); | |
| for (let i = 0; i < TESTS.length; i++) { | |
| await runTest(i); | |
| setProgress((i + 1) / TESTS.length); | |
| } | |
| setRunningAll(false); | |
| }, [runTest]); | |
| const categories = ['Re-encode', 'Downscale', 'Brightness', 'Contrast', 'Saturation', 'Crop']; | |
| return ( | |
| <div className="space-y-4"> | |
| <div className="flex items-center justify-between"> | |
| <h4 className="text-sm font-semibold text-zinc-300">Robustness Testing</h4> | |
| <button | |
| onClick={runAll} | |
| disabled={runningAll} | |
| className="rounded-lg bg-zinc-800 px-3 py-1.5 text-xs font-medium text-zinc-300 | |
| ring-1 ring-zinc-700 transition-all hover:bg-zinc-700 hover:text-zinc-100 | |
| disabled:cursor-not-allowed disabled:opacity-50" | |
| > | |
| {runningAll ? ( | |
| <span className="flex items-center gap-1.5"> | |
| <span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-zinc-500 border-t-zinc-200" /> | |
| {Math.round(progress * 100)}% | |
| </span> | |
| ) : ( | |
| 'Run All Tests' | |
| )} | |
| </button> | |
| </div> | |
| {runningAll && ( | |
| <div className="h-1 w-full overflow-hidden rounded-full bg-zinc-800"> | |
| <div | |
| className="h-full rounded-full bg-zinc-500 transition-all duration-300" | |
| style={{ width: `${progress * 100}%` }} | |
| /> | |
| </div> | |
| )} | |
| <div className="space-y-3"> | |
| {categories.map((cat) => { | |
| const catTests = TESTS.map((t, i) => ({ ...t, idx: i })).filter((t) => t.category === cat); | |
| return ( | |
| <div key={cat} className="flex items-center gap-2"> | |
| <span className="w-24 shrink-0 text-xs text-zinc-500">{cat}</span> | |
| <div className="flex flex-wrap gap-1.5"> | |
| {catTests.map(({ label, idx }) => { | |
| const r = results[idx]; | |
| const status = r?.status ?? 'idle'; | |
| return ( | |
| <button | |
| key={idx} | |
| onClick={() => runTest(idx)} | |
| disabled={status === 'running' || runningAll} | |
| className={`inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium | |
| ring-1 transition-all disabled:cursor-not-allowed | |
| ${status === 'idle' ? 'bg-zinc-800/80 text-zinc-400 ring-zinc-700 hover:bg-zinc-700 hover:text-zinc-200' : ''} | |
| ${status === 'running' ? 'bg-zinc-800 text-zinc-400 ring-zinc-700' : ''} | |
| ${status === 'pass' ? 'bg-emerald-950/40 text-emerald-400 ring-emerald-800/50' : ''} | |
| ${status === 'fail' ? 'bg-red-950/40 text-red-400 ring-red-800/50' : ''} | |
| ${status === 'error' ? 'bg-amber-950/40 text-amber-400 ring-amber-800/50' : ''} | |
| `} | |
| > | |
| {status === 'running' && ( | |
| <span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-zinc-500 border-t-zinc-200" /> | |
| )} | |
| {status === 'pass' && <span>✅</span>} | |
| {status === 'fail' && <span>❌</span>} | |
| {status === 'error' && <span>⚠️</span>} | |
| {label} | |
| {r?.confidence !== undefined && ( | |
| <span className="ml-0.5 opacity-70">{(r.confidence * 100).toFixed(0)}%</span> | |
| )} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| ); | |
| } | |