harelcain commited on
Commit
44f463d
·
verified ·
1 Parent(s): d283c04

Upload 16 files

Browse files
web/.DS_Store CHANGED
Binary files a/web/.DS_Store and b/web/.DS_Store differ
 
web/src/.DS_Store CHANGED
Binary files a/web/src/.DS_Store and b/web/src/.DS_Store differ
 
web/src/components/DetectPanel.tsx CHANGED
@@ -9,6 +9,7 @@ export default function DetectPanel() {
9
  const [videoName, setVideoName] = useState('');
10
  const [key, setKey] = useState('');
11
  const [maxFrames, setMaxFrames] = useState(10);
 
12
  const [processing, setProcessing] = useState(false);
13
  const [progress, setProgress] = useState({ phase: '', current: 0, total: 0 });
14
  const [result, setResult] = useState<AutoDetectResult | null>(null);
@@ -48,7 +49,7 @@ export default function DetectPanel() {
48
  });
49
 
50
  setProgress({ phase: 'Trying all presets', current: 0, total: 0 });
51
- const detection = autoDetectMultiFrame(yPlanes, width, height, key);
52
 
53
  setResult(detection);
54
  } catch (e) {
@@ -127,6 +128,22 @@ export default function DetectPanel() {
127
  </div>
128
  </div>
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  <p className="text-xs text-zinc-500">
131
  All presets will be tried automatically. No need to know which preset was used during embedding.
132
  </p>
 
9
  const [videoName, setVideoName] = useState('');
10
  const [key, setKey] = useState('');
11
  const [maxFrames, setMaxFrames] = useState(10);
12
+ const [cropResilient, setCropResilient] = useState(false);
13
  const [processing, setProcessing] = useState(false);
14
  const [progress, setProgress] = useState({ phase: '', current: 0, total: 0 });
15
  const [result, setResult] = useState<AutoDetectResult | null>(null);
 
49
  });
50
 
51
  setProgress({ phase: 'Trying all presets', current: 0, total: 0 });
52
+ const detection = autoDetectMultiFrame(yPlanes, width, height, key, { cropResilient });
53
 
54
  setResult(detection);
55
  } catch (e) {
 
128
  </div>
129
  </div>
130
 
131
+ <div className="flex items-center gap-3">
132
+ <label className="relative inline-flex cursor-pointer items-center">
133
+ <input
134
+ type="checkbox"
135
+ checked={cropResilient}
136
+ onChange={(e) => setCropResilient(e.target.checked)}
137
+ className="peer sr-only"
138
+ />
139
+ <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" />
140
+ </label>
141
+ <div>
142
+ <span className="text-sm text-zinc-300">Crop-resilient detection</span>
143
+ <p className="text-[10px] text-zinc-600">Slower — brute-forces DWT alignment for cropped videos</p>
144
+ </div>
145
+ </div>
146
+
147
  <p className="text-xs text-zinc-500">
148
  All presets will be tried automatically. No need to know which preset was used during embedding.
149
  </p>
web/src/components/EmbedPanel.tsx CHANGED
@@ -41,6 +41,8 @@ export default function EmbedPanel() {
41
  width: number;
42
  height: number;
43
  fps: number;
 
 
44
  } | null>(null);
45
  const fileRef = useRef<HTMLInputElement>(null);
46
 
@@ -105,6 +107,8 @@ export default function EmbedPanel() {
105
  width: embedResult.width,
106
  height: embedResult.height,
107
  fps: embedResult.fps,
 
 
108
  });
109
  } catch (e) {
110
  console.error('Embed error:', e);
@@ -246,6 +250,8 @@ export default function EmbedPanel() {
246
  <h3 className="text-sm font-semibold text-emerald-400">✅ Embedding Complete</h3>
247
  <p className="mt-1 text-xs text-zinc-500">
248
  {result.frames} frames processed — Average PSNR: {result.psnr.toFixed(1)} dB
 
 
249
  </p>
250
  </div>
251
  <button
 
41
  width: number;
42
  height: number;
43
  fps: number;
44
+ embedTimeMs: number;
45
+ pixelsPerSecond: number;
46
  } | null>(null);
47
  const fileRef = useRef<HTMLInputElement>(null);
48
 
 
107
  width: embedResult.width,
108
  height: embedResult.height,
109
  fps: embedResult.fps,
110
+ embedTimeMs: embedResult.embedTimeMs,
111
+ pixelsPerSecond: embedResult.pixelsPerSecond,
112
  });
113
  } catch (e) {
114
  console.error('Embed error:', e);
 
250
  <h3 className="text-sm font-semibold text-emerald-400">✅ Embedding Complete</h3>
251
  <p className="mt-1 text-xs text-zinc-500">
252
  {result.frames} frames processed — Average PSNR: {result.psnr.toFixed(1)} dB
253
+ <br />
254
+ Embedded in {(result.embedTimeMs / 1000).toFixed(1)}s — {(result.pixelsPerSecond / 1e6).toFixed(1)} Mpx/s
255
  </p>
256
  </div>
257
  <button
web/src/components/HowItWorks.tsx CHANGED
@@ -590,15 +590,15 @@ const stages: Stage[] = [
590
  title: 'Payload Encoding',
591
  description: 'Error-correcting payload encoding',
592
  explanation: [
593
- 'The raw 32-bit payload (e.g. 0xDEADBEEF) is first protected with a 16-bit CRC checksum for integrity verification, expanding to 48 bits. Then BCH(63,48,2) error-correcting coding adds parity bits, yielding 63 coded bits that can correct up to 2 bit errors.',
594
  'Finally, the 63 bits are pseudo-randomly interleaved using a key-derived permutation. This spreads burst errors (from localized damage) across the codeword, converting them into scattered errors that BCH can correct more effectively.',
595
  ],
596
  draw(g, w, h) {
597
  const cy = h / 2;
598
  const steps = [
599
  { label: '32-bit', label2: 'payload', bits: '32', color: C.blue, desc: '0xDEADBEEF' },
600
- { label: 'CRC-16', label2: 'append', bits: '48', color: C.violet, desc: '+16 check bits' },
601
- { label: 'BCH', label2: '(63,48,t=2)', bits: '63', color: C.emerald, desc: 'correct 2 errors' },
602
  { label: 'Keyed', label2: 'interleave', bits: '63', color: C.amber, desc: 'spread burst errors' },
603
  ];
604
  const pad = 30;
 
590
  title: 'Payload Encoding',
591
  description: 'Error-correcting payload encoding',
592
  explanation: [
593
+ 'The raw 32-bit payload (e.g. 0xDEADBEEF) is first protected with a 4-bit CRC checksum for integrity verification, expanding to 36 bits. Then BCH(63,36,5) error-correcting coding adds parity bits, yielding 63 coded bits that can correct up to 5 bit errors.',
594
  'Finally, the 63 bits are pseudo-randomly interleaved using a key-derived permutation. This spreads burst errors (from localized damage) across the codeword, converting them into scattered errors that BCH can correct more effectively.',
595
  ],
596
  draw(g, w, h) {
597
  const cy = h / 2;
598
  const steps = [
599
  { label: '32-bit', label2: 'payload', bits: '32', color: C.blue, desc: '0xDEADBEEF' },
600
+ { label: 'CRC-4', label2: 'append', bits: '36', color: C.violet, desc: '+4 check bits' },
601
+ { label: 'BCH', label2: '(63,36,t=5)', bits: '63', color: C.emerald, desc: 'correct 5 errors' },
602
  { label: 'Keyed', label2: 'interleave', bits: '63', color: C.amber, desc: 'spread burst errors' },
603
  ];
604
  const pad = 30;
web/src/components/RobustnessTest.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import { useState, useCallback } from 'react';
2
- import { autoDetectMultiFrame } from '@core/detector.js';
3
- import { attackReencode, attackDownscale, attackBrightness, attackContrast, attackSaturation } from '../lib/video-io.js';
4
 
5
  type TestStatus = 'idle' | 'running' | 'pass' | 'fail' | 'error';
6
 
@@ -22,6 +22,7 @@ interface TestDef {
22
  label: string;
23
  category: string;
24
  run: (blob: Blob, w: number, h: number) => Promise<{ yPlanes: Uint8Array[]; width: number; height: number }>;
 
25
  }
26
 
27
  const TESTS: TestDef[] = [
@@ -32,6 +33,7 @@ const TESTS: TestDef[] = [
32
  { label: 'CRF 38', category: 'Re-encode', run: (b, w, h) => attackReencode(b, 38, w, h) },
33
  { label: 'CRF 43', category: 'Re-encode', run: (b, w, h) => attackReencode(b, 43, w, h) },
34
  // Downscale
 
35
  { label: '50%', category: 'Downscale', run: (b, w, h) => attackDownscale(b, 50, w, h) },
36
  { label: '75%', category: 'Downscale', run: (b, w, h) => attackDownscale(b, 75, w, h) },
37
  { label: '90%', category: 'Downscale', run: (b, w, h) => attackDownscale(b, 90, w, h) },
@@ -47,6 +49,23 @@ const TESTS: TestDef[] = [
47
  { label: '0x', category: 'Saturation', run: (b, w, h) => attackSaturation(b, 0, w, h) },
48
  { label: '0.5x', category: 'Saturation', run: (b, w, h) => attackSaturation(b, 0.5, w, h) },
49
  { label: '2.0x', category: 'Saturation', run: (b, w, h) => attackSaturation(b, 2.0, w, h) },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  ];
51
 
52
  function payloadToHex(payload: Uint8Array): string {
@@ -56,6 +75,7 @@ function payloadToHex(payload: Uint8Array): string {
56
  export default function RobustnessTest({ blob, width, height, payload, secretKey }: RobustnessTestProps) {
57
  const [results, setResults] = useState<Record<number, TestResult>>({});
58
  const [runningAll, setRunningAll] = useState(false);
 
59
 
60
  const expectedHex = payload.replace(/^0x/, '').toUpperCase();
61
 
@@ -69,7 +89,7 @@ export default function RobustnessTest({ blob, width, height, payload, secretKey
69
  const step = Math.max(1, Math.floor(attacked.yPlanes.length / 10));
70
  const sampled = attacked.yPlanes.filter((_, i) => i % step === 0).slice(0, 10);
71
 
72
- const detection = autoDetectMultiFrame(sampled, attacked.width, attacked.height, secretKey);
73
 
74
  const detectedHex = detection.payload ? payloadToHex(detection.payload) : '';
75
  const match = detection.detected && detectedHex === expectedHex;
@@ -90,13 +110,15 @@ export default function RobustnessTest({ blob, width, height, payload, secretKey
90
 
91
  const runAll = useCallback(async () => {
92
  setRunningAll(true);
 
93
  for (let i = 0; i < TESTS.length; i++) {
94
  await runTest(i);
 
95
  }
96
  setRunningAll(false);
97
  }, [runTest]);
98
 
99
- const categories = ['Re-encode', 'Downscale', 'Brightness', 'Contrast', 'Saturation'];
100
 
101
  return (
102
  <div className="space-y-4">
@@ -112,7 +134,7 @@ export default function RobustnessTest({ blob, width, height, payload, secretKey
112
  {runningAll ? (
113
  <span className="flex items-center gap-1.5">
114
  <span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-zinc-500 border-t-zinc-200" />
115
- Running...
116
  </span>
117
  ) : (
118
  'Run All Tests'
@@ -120,6 +142,15 @@ export default function RobustnessTest({ blob, width, height, payload, secretKey
120
  </button>
121
  </div>
122
 
 
 
 
 
 
 
 
 
 
123
  <div className="space-y-3">
124
  {categories.map((cat) => {
125
  const catTests = TESTS.map((t, i) => ({ ...t, idx: i })).filter((t) => t.category === cat);
 
1
  import { useState, useCallback } from 'react';
2
+ import { autoDetectMultiFrame, type DetectOptions } from '@core/detector.js';
3
+ import { attackReencode, attackDownscale, attackBrightness, attackContrast, attackSaturation, attackCrop } from '../lib/video-io.js';
4
 
5
  type TestStatus = 'idle' | 'running' | 'pass' | 'fail' | 'error';
6
 
 
22
  label: string;
23
  category: string;
24
  run: (blob: Blob, w: number, h: number) => Promise<{ yPlanes: Uint8Array[]; width: number; height: number }>;
25
+ detectOptions?: DetectOptions;
26
  }
27
 
28
  const TESTS: TestDef[] = [
 
33
  { label: 'CRF 38', category: 'Re-encode', run: (b, w, h) => attackReencode(b, 38, w, h) },
34
  { label: 'CRF 43', category: 'Re-encode', run: (b, w, h) => attackReencode(b, 43, w, h) },
35
  // Downscale
36
+ { label: '25%', category: 'Downscale', run: (b, w, h) => attackDownscale(b, 25, w, h) },
37
  { label: '50%', category: 'Downscale', run: (b, w, h) => attackDownscale(b, 50, w, h) },
38
  { label: '75%', category: 'Downscale', run: (b, w, h) => attackDownscale(b, 75, w, h) },
39
  { label: '90%', category: 'Downscale', run: (b, w, h) => attackDownscale(b, 90, w, h) },
 
49
  { label: '0x', category: 'Saturation', run: (b, w, h) => attackSaturation(b, 0, w, h) },
50
  { label: '0.5x', category: 'Saturation', run: (b, w, h) => attackSaturation(b, 0.5, w, h) },
51
  { label: '2.0x', category: 'Saturation', run: (b, w, h) => attackSaturation(b, 2.0, w, h) },
52
+ // Crop (~5-10% from edges)
53
+ { label: '5%', category: 'Crop', run: (b, w, h) => {
54
+ const px = Math.round(Math.min(w, h) * 0.05);
55
+ return attackCrop(b, px, px, px, px, w, h);
56
+ }, detectOptions: { cropResilient: true } },
57
+ { label: '10%', category: 'Crop', run: (b, w, h) => {
58
+ const px = Math.round(Math.min(w, h) * 0.10);
59
+ return attackCrop(b, px, px, px, px, w, h);
60
+ }, detectOptions: { cropResilient: true } },
61
+ { label: '15%', category: 'Crop', run: (b, w, h) => {
62
+ const px = Math.round(Math.min(w, h) * 0.15);
63
+ return attackCrop(b, px, px, px, px, w, h);
64
+ }, detectOptions: { cropResilient: true } },
65
+ { label: '20%', category: 'Crop', run: (b, w, h) => {
66
+ const px = Math.round(Math.min(w, h) * 0.20);
67
+ return attackCrop(b, px, px, px, px, w, h);
68
+ }, detectOptions: { cropResilient: true } },
69
  ];
70
 
71
  function payloadToHex(payload: Uint8Array): string {
 
75
  export default function RobustnessTest({ blob, width, height, payload, secretKey }: RobustnessTestProps) {
76
  const [results, setResults] = useState<Record<number, TestResult>>({});
77
  const [runningAll, setRunningAll] = useState(false);
78
+ const [progress, setProgress] = useState(0);
79
 
80
  const expectedHex = payload.replace(/^0x/, '').toUpperCase();
81
 
 
89
  const step = Math.max(1, Math.floor(attacked.yPlanes.length / 10));
90
  const sampled = attacked.yPlanes.filter((_, i) => i % step === 0).slice(0, 10);
91
 
92
+ const detection = autoDetectMultiFrame(sampled, attacked.width, attacked.height, secretKey, test.detectOptions);
93
 
94
  const detectedHex = detection.payload ? payloadToHex(detection.payload) : '';
95
  const match = detection.detected && detectedHex === expectedHex;
 
110
 
111
  const runAll = useCallback(async () => {
112
  setRunningAll(true);
113
+ setProgress(0);
114
  for (let i = 0; i < TESTS.length; i++) {
115
  await runTest(i);
116
+ setProgress((i + 1) / TESTS.length);
117
  }
118
  setRunningAll(false);
119
  }, [runTest]);
120
 
121
+ const categories = ['Re-encode', 'Downscale', 'Brightness', 'Contrast', 'Saturation', 'Crop'];
122
 
123
  return (
124
  <div className="space-y-4">
 
134
  {runningAll ? (
135
  <span className="flex items-center gap-1.5">
136
  <span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-zinc-500 border-t-zinc-200" />
137
+ {Math.round(progress * 100)}%
138
  </span>
139
  ) : (
140
  'Run All Tests'
 
142
  </button>
143
  </div>
144
 
145
+ {runningAll && (
146
+ <div className="h-1 w-full overflow-hidden rounded-full bg-zinc-800">
147
+ <div
148
+ className="h-full rounded-full bg-zinc-500 transition-all duration-300"
149
+ style={{ width: `${progress * 100}%` }}
150
+ />
151
+ </div>
152
+ )}
153
+
154
  <div className="space-y-3">
155
  {categories.map((cat) => {
156
  const catTests = TESTS.map((t, i) => ({ ...t, idx: i })).filter((t) => t.category === cat);
web/src/lib/video-io.ts CHANGED
@@ -65,6 +65,8 @@ export interface StreamEmbedResult {
65
  avgPsnr: number;
66
  sampleOriginal: ImageData[];
67
  sampleWatermarked: ImageData[];
 
 
68
  }
69
 
70
  /** Number of frames to accumulate before flushing to H.264 encoder */
@@ -85,6 +87,8 @@ export async function streamExtractAndEmbed(
85
  comparisonSample: number,
86
  onProgress?: (phase: string, current: number, total: number) => void
87
  ): Promise<StreamEmbedResult> {
 
 
88
  const video = document.createElement('video');
89
  video.src = videoUrl;
90
  video.muted = true;
@@ -283,6 +287,9 @@ export async function streamExtractAndEmbed(
283
  try { await ffmpeg.deleteFile('audio_track.m4a'); } catch { /* no-op */ }
284
  }
285
 
 
 
 
286
  return {
287
  blob,
288
  width,
@@ -292,6 +299,8 @@ export async function streamExtractAndEmbed(
292
  avgPsnr: totalPsnr / totalFrames,
293
  sampleOriginal,
294
  sampleWatermarked,
 
 
295
  };
296
  }
297
 
@@ -451,3 +460,26 @@ export async function attackSaturation(blob: Blob, factor: number, width: number
451
  return runAttackPipeline(blob, ['-vf', `eq=saturation=${factor}`, '-crf', '18'], width, height);
452
  }
453
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  avgPsnr: number;
66
  sampleOriginal: ImageData[];
67
  sampleWatermarked: ImageData[];
68
+ embedTimeMs: number;
69
+ pixelsPerSecond: number;
70
  }
71
 
72
  /** Number of frames to accumulate before flushing to H.264 encoder */
 
87
  comparisonSample: number,
88
  onProgress?: (phase: string, current: number, total: number) => void
89
  ): Promise<StreamEmbedResult> {
90
+ const t0 = performance.now();
91
+
92
  const video = document.createElement('video');
93
  video.src = videoUrl;
94
  video.muted = true;
 
287
  try { await ffmpeg.deleteFile('audio_track.m4a'); } catch { /* no-op */ }
288
  }
289
 
290
+ const embedTimeMs = performance.now() - t0;
291
+ const pixelsPerSecond = (width * height * totalFrames) / (embedTimeMs / 1000);
292
+
293
  return {
294
  blob,
295
  width,
 
299
  avgPsnr: totalPsnr / totalFrames,
300
  sampleOriginal,
301
  sampleWatermarked,
302
+ embedTimeMs,
303
+ pixelsPerSecond,
304
  };
305
  }
306
 
 
460
  return runAttackPipeline(blob, ['-vf', `eq=saturation=${factor}`, '-crf', '18'], width, height);
461
  }
462
 
463
+ /** Attack: crop by given pixel amounts from each edge */
464
+ export async function attackCrop(
465
+ blob: Blob,
466
+ cropLeft: number,
467
+ cropTop: number,
468
+ cropRight: number,
469
+ cropBottom: number,
470
+ width: number,
471
+ height: number,
472
+ ): Promise<AttackYPlanes> {
473
+ const outW = width - cropLeft - cropRight;
474
+ const outH = height - cropTop - cropBottom;
475
+ // Ensure even dimensions for libx264
476
+ const w = outW % 2 === 0 ? outW : outW - 1;
477
+ const h = outH % 2 === 0 ? outH : outH - 1;
478
+ return runAttackPipeline(
479
+ blob,
480
+ ['-vf', `crop=${w}:${h}:${cropLeft}:${cropTop}`, '-crf', '18'],
481
+ w,
482
+ h,
483
+ );
484
+ }
485
+