harelcain commited on
Commit
86323af
·
verified ·
1 Parent(s): f2f99a3

Upload 29 files

Browse files
core/detector.ts CHANGED
@@ -81,7 +81,7 @@ function extractSoftBitsFromSubband(
81
  const blocks = getTileBlocks(origin.x, origin.y, tileGrid.tilePeriod, hlSubband.width, hlSubband.height);
82
 
83
  const softBits = new Float64Array(codedLength);
84
- const bitCounts = new Float64Array(codedLength);
85
 
86
  let maskingFactors: Float64Array | null = null;
87
  if (config.perceptualMasking && blocks.length > 0) {
@@ -103,6 +103,11 @@ function extractSoftBitsFromSubband(
103
  const maskFactor = maskingFactors ? maskingFactors[bi] : 1.0;
104
  const effectiveDelta = config.delta * maskFactor;
105
 
 
 
 
 
 
106
  for (let z = 0; z < zigCoeffIdx.length; z++) {
107
  if (bitIdx >= codedLength) bitIdx = 0;
108
 
@@ -110,15 +115,15 @@ function extractSoftBitsFromSubband(
110
  const dither = dithers[ditherIdx++];
111
 
112
  const soft = dmqimExtractSoft(blockBuf[coeffIdx], effectiveDelta, dither);
113
- softBits[bitIdx] += soft;
114
- bitCounts[bitIdx]++;
115
 
116
  bitIdx++;
117
  }
118
  }
119
 
120
  for (let i = 0; i < codedLength; i++) {
121
- if (bitCounts[i] > 0) softBits[i] /= bitCounts[i];
122
  }
123
 
124
  tileSoftBits.push(softBits);
 
81
  const blocks = getTileBlocks(origin.x, origin.y, tileGrid.tilePeriod, hlSubband.width, hlSubband.height);
82
 
83
  const softBits = new Float64Array(codedLength);
84
+ const bitWeights = new Float64Array(codedLength);
85
 
86
  let maskingFactors: Float64Array | null = null;
87
  if (config.perceptualMasking && blocks.length > 0) {
 
103
  const maskFactor = maskingFactors ? maskingFactors[bi] : 1.0;
104
  const effectiveDelta = config.delta * maskFactor;
105
 
106
+ // Weight by masking factor: blocks with tiny effective delta produce
107
+ // unreliable soft bits (noise magnitude scales as 1/delta), so their
108
+ // contribution should be proportionally smaller.
109
+ const weight = maskFactor;
110
+
111
  for (let z = 0; z < zigCoeffIdx.length; z++) {
112
  if (bitIdx >= codedLength) bitIdx = 0;
113
 
 
115
  const dither = dithers[ditherIdx++];
116
 
117
  const soft = dmqimExtractSoft(blockBuf[coeffIdx], effectiveDelta, dither);
118
+ softBits[bitIdx] += soft * weight;
119
+ bitWeights[bitIdx] += weight;
120
 
121
  bitIdx++;
122
  }
123
  }
124
 
125
  for (let i = 0; i < codedLength; i++) {
126
+ if (bitWeights[i] > 0) softBits[i] /= bitWeights[i];
127
  }
128
 
129
  tileSoftBits.push(softBits);
core/masking.ts CHANGED
@@ -23,9 +23,12 @@ export function blockAcEnergy(dctBlock: Float64Array): number {
23
  * Returns per-block multiplier for delta:
24
  * Δ_effective = Δ_base × masking_factor
25
  *
26
- * Factors are in [0.5, 2.0]:
27
- * - Smooth blocks → factor < 1 (reduce strength)
28
- * - Textured blocks → factor > 1 (can increase strength)
 
 
 
29
  *
30
  * @param blockEnergies - AC energy for each block
31
  * @returns Array of masking factors, same length as input
@@ -46,8 +49,11 @@ export function computeMaskingFactors(blockEnergies: Float64Array): Float64Array
46
  const factors = new Float64Array(n);
47
  for (let i = 0; i < n; i++) {
48
  const ratio = blockEnergies[i] / safeMedian;
49
- // Clamp to [0.5, 2.0]
50
- factors[i] = Math.max(0.5, Math.min(2.0, ratio));
 
 
 
51
  }
52
 
53
  return factors;
 
23
  * Returns per-block multiplier for delta:
24
  * Δ_effective = Δ_base × masking_factor
25
  *
26
+ * Factors are in [0.1, 2.0]:
27
+ * - Smooth/flat blocks → factor near 0.1 (barely embed — changes would be visible)
28
+ * - Textured/noisy blocks → factor up to 2.0 (can embed aggressively)
29
+ *
30
+ * Uses a square-root curve so that low-energy blocks are suppressed
31
+ * more aggressively than a linear mapping would.
32
  *
33
  * @param blockEnergies - AC energy for each block
34
  * @returns Array of masking factors, same length as input
 
49
  const factors = new Float64Array(n);
50
  for (let i = 0; i < n; i++) {
51
  const ratio = blockEnergies[i] / safeMedian;
52
+ // sqrt curve: suppresses low-energy blocks more aggressively
53
+ // ratio=0 → 0, ratio=0.25 → 0.5, ratio=1 → 1, ratio=4 → 2
54
+ const curved = Math.sqrt(ratio);
55
+ // Clamp to [0.1, 2.0] — flat areas barely embed
56
+ factors[i] = Math.max(0.1, Math.min(2.0, curved));
57
  }
58
 
59
  return factors;
core/presets.ts CHANGED
@@ -18,14 +18,14 @@ export const PRESETS: Record<PresetName, WatermarkConfig> = {
18
  crc: { bits: 4 },
19
  dwtLevels: 2,
20
  zigzagPositions: MID_FREQ_ZIGZAG,
21
- perceptualMasking: false,
22
  temporalFrames: 1,
23
  },
24
 
25
  moderate: {
26
- alpha: 0.18,
27
- delta: 80,
28
- tilePeriod: 232,
29
  bch: { n: 63, k: 36, t: 5, m: 6 },
30
  crc: { bits: 4 },
31
  dwtLevels: 2,
@@ -71,7 +71,7 @@ export function getPreset(name: PresetName): WatermarkConfig {
71
  */
72
  export const PRESET_DESCRIPTIONS: Record<PresetName, string> = {
73
  light: '🌤️ Lightest touch. Near-invisible, survives mild compression.',
74
- moderate: '⚡ Balanced embedding with perceptual masking. Good compression resilience.',
75
  strong: '🛡️ Stronger embedding across more frequencies. Handles rescaling well.',
76
  fortress: '🏰 Maximum robustness. Survives heavy compression and color adjustments.',
77
  };
 
18
  crc: { bits: 4 },
19
  dwtLevels: 2,
20
  zigzagPositions: MID_FREQ_ZIGZAG,
21
+ perceptualMasking: true,
22
  temporalFrames: 1,
23
  },
24
 
25
  moderate: {
26
+ alpha: 0.13,
27
+ delta: 62,
28
+ tilePeriod: 240,
29
  bch: { n: 63, k: 36, t: 5, m: 6 },
30
  crc: { bits: 4 },
31
  dwtLevels: 2,
 
71
  */
72
  export const PRESET_DESCRIPTIONS: Record<PresetName, string> = {
73
  light: '🌤️ Lightest touch. Near-invisible, survives mild compression.',
74
+ moderate: '⚡ Near-invisible with perceptual masking. Slightly stronger than light.',
75
  strong: '🛡️ Stronger embedding across more frequencies. Handles rescaling well.',
76
  fortress: '🏰 Maximum robustness. Survives heavy compression and color adjustments.',
77
  };
core/tiling.ts CHANGED
@@ -34,10 +34,15 @@ export function computeTileGrid(
34
  subbandHeight: number,
35
  tilePeriod: number
36
  ): TileGrid {
37
- const tilesX = Math.floor(subbandWidth / tilePeriod);
38
- const tilesY = Math.floor(subbandHeight / tilePeriod);
 
 
 
 
 
39
  return {
40
- tilePeriod,
41
  phaseX: 0,
42
  phaseY: 0,
43
  tilesX,
 
34
  subbandHeight: number,
35
  tilePeriod: number
36
  ): TileGrid {
37
+ // Snap tile period down to a multiple of 8 so that DCT blocks
38
+ // perfectly tile each tile with no leftover strips (which would
39
+ // show up as unembedded grid lines in the diff).
40
+ const snapped = Math.floor(tilePeriod / 8) * 8;
41
+ const effectivePeriod = Math.max(8, snapped);
42
+ const tilesX = Math.floor(subbandWidth / effectivePeriod);
43
+ const tilesY = Math.floor(subbandHeight / effectivePeriod);
44
  return {
45
+ tilePeriod: effectivePeriod,
46
  phaseX: 0,
47
  phaseY: 0,
48
  tilesX,
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/App.tsx CHANGED
@@ -2,26 +2,26 @@ import { useState } from 'react';
2
  import EmbedPanel from './components/EmbedPanel.js';
3
  import DetectPanel from './components/DetectPanel.js';
4
  import ApiDocs from './components/ApiDocs.js';
 
5
 
6
  export default function App() {
7
- const [tab, setTab] = useState<'embed' | 'detect' | 'api'>('embed');
 
 
8
 
9
  return (
10
  <div className="min-h-screen bg-zinc-950">
11
  <header className="sticky top-0 z-10 border-b border-zinc-800/50 bg-zinc-950/80 px-6 py-4 backdrop-blur-xl">
12
- <div className="mx-auto flex max-w-2xl items-center justify-between">
13
  <div className="flex items-center gap-3">
14
- <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-600 to-violet-600 shadow-lg shadow-blue-600/20">
15
- <svg className="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
16
  <path strokeLinecap="round" strokeLinejoin="round" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
17
  </svg>
18
  </div>
19
- <div>
20
- <h1 className="text-lg font-bold tracking-tight text-zinc-100">
21
- LTMar<span className="text-blue-400">X</span>
22
- </h1>
23
- <p className="text-[10px] text-zinc-600">The watermark bits your generated video didn't even know it needs</p>
24
- </div>
25
  </div>
26
  <nav className="flex gap-1 rounded-xl bg-zinc-900/80 p-1 ring-1 ring-zinc-800/50">
27
  <button
@@ -54,31 +54,43 @@ export default function App() {
54
  >
55
  {'{}'} API
56
  </button>
 
 
 
 
 
 
 
 
 
 
57
  </nav>
58
  </div>
59
  </header>
60
 
61
- <main className="mx-auto max-w-2xl px-6 py-10">
62
  <div className="mb-8">
63
  <h2 className="text-2xl font-bold tracking-tight text-zinc-100">
64
- {tab === 'embed' ? '🎬 Embed Watermark' : tab === 'detect' ? '🔎 Detect Watermark' : '📖 API Reference'}
65
  </h2>
66
  <p className="mt-1 text-sm text-zinc-500">
67
  {tab === 'embed'
68
  ? 'Embed an imperceptible 32-bit payload into your video.'
69
  : tab === 'detect'
70
  ? 'Analyze a video to detect and extract embedded watermarks.'
71
- : 'Integrate LTMarX into your application.'}
 
 
72
  </p>
73
  </div>
74
 
75
- {tab === 'embed' ? <EmbedPanel /> : tab === 'detect' ? <DetectPanel /> : <ApiDocs />}
76
  </main>
77
 
78
  <footer className="border-t border-zinc-800/30 px-6 py-6">
79
- <div className="mx-auto max-w-2xl">
80
  <p className="text-center text-[11px] text-zinc-700">
81
- LTMar<span className="text-zinc-600">X</span> — DWT/DCT watermarking with DM-QIM and BCH error correction.
82
  All processing happens in your browser.
83
  </p>
84
  </div>
 
2
  import EmbedPanel from './components/EmbedPanel.js';
3
  import DetectPanel from './components/DetectPanel.js';
4
  import ApiDocs from './components/ApiDocs.js';
5
+ import HowItWorks from './components/HowItWorks.js';
6
 
7
  export default function App() {
8
+ const [tab, setTab] = useState<'embed' | 'detect' | 'api' | 'how'>('embed');
9
+
10
+ const isWide = tab === 'how';
11
 
12
  return (
13
  <div className="min-h-screen bg-zinc-950">
14
  <header className="sticky top-0 z-10 border-b border-zinc-800/50 bg-zinc-950/80 px-6 py-4 backdrop-blur-xl">
15
+ <div className={`mx-auto flex items-center justify-between ${isWide ? 'max-w-4xl' : 'max-w-2xl'}`}>
16
  <div className="flex items-center gap-3">
17
+ <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-blue-600 to-violet-600 shadow-lg shadow-blue-600/20">
18
+ <svg className="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
19
  <path strokeLinecap="round" strokeLinejoin="round" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
20
  </svg>
21
  </div>
22
+ <h1 className="text-xl font-bold tracking-tight text-zinc-100">
23
+ <span className="text-blue-400">L</span><span className="text-blue-400">T</span>Mar<span className="text-blue-400">X</span>
24
+ </h1>
 
 
 
25
  </div>
26
  <nav className="flex gap-1 rounded-xl bg-zinc-900/80 p-1 ring-1 ring-zinc-800/50">
27
  <button
 
54
  >
55
  {'{}'} API
56
  </button>
57
+ <button
58
+ onClick={() => setTab('how')}
59
+ className={`rounded-lg px-4 py-1.5 text-sm font-medium transition-all ${
60
+ tab === 'how'
61
+ ? 'bg-zinc-800 text-zinc-100 shadow-sm'
62
+ : 'text-zinc-400 hover:text-zinc-200'
63
+ }`}
64
+ >
65
+ 📐 How
66
+ </button>
67
  </nav>
68
  </div>
69
  </header>
70
 
71
+ <main className={`mx-auto px-6 py-10 ${isWide ? 'max-w-4xl' : 'max-w-2xl'}`}>
72
  <div className="mb-8">
73
  <h2 className="text-2xl font-bold tracking-tight text-zinc-100">
74
+ {tab === 'embed' ? '🎬 Embed Watermark' : tab === 'detect' ? '🔎 Detect Watermark' : tab === 'api' ? '📖 API Reference' : '📐 How It Works'}
75
  </h2>
76
  <p className="mt-1 text-sm text-zinc-500">
77
  {tab === 'embed'
78
  ? 'Embed an imperceptible 32-bit payload into your video.'
79
  : tab === 'detect'
80
  ? 'Analyze a video to detect and extract embedded watermarks.'
81
+ : tab === 'api'
82
+ ? 'Integrate LTMarX into your application.'
83
+ : 'Interactive walkthrough of the DWT/DCT watermarking pipeline.'}
84
  </p>
85
  </div>
86
 
87
+ {tab === 'embed' ? <EmbedPanel /> : tab === 'detect' ? <DetectPanel /> : tab === 'api' ? <ApiDocs /> : <HowItWorks />}
88
  </main>
89
 
90
  <footer className="border-t border-zinc-800/30 px-6 py-6">
91
+ <div className={`mx-auto ${isWide ? 'max-w-4xl' : 'max-w-2xl'}`}>
92
  <p className="text-center text-[11px] text-zinc-700">
93
+ <span className="text-zinc-600">L</span><span className="text-zinc-600">T</span>Mar<span className="text-zinc-600">X</span> — DWT/DCT watermarking with DM-QIM and BCH error correction.
94
  All processing happens in your browser.
95
  </p>
96
  </div>
web/src/components/ApiDocs.tsx CHANGED
@@ -136,7 +136,8 @@ const result = autoDetectMultiFrame(yPlanes, width, height, secretKey);
136
  {/* HTTP API */}
137
  <Section title="HTTP API">
138
  <p className="text-xs text-zinc-400">
139
- Available when running the server via <code className="text-zinc-300">tsx server/api.ts</code> or Docker.
 
140
  </p>
141
 
142
  <div className="space-y-4">
@@ -149,8 +150,9 @@ const result = autoDetectMultiFrame(yPlanes, width, height, secretKey);
149
  { name: 'payload', type: 'string', desc: 'Hex string, up to 8 chars (32 bits)', required: true },
150
  ]} />
151
  <CodeBlock lang="bash">{`
152
- curl -X POST http://localhost:7860/api/embed \\
153
  -H "Content-Type: application/json" \\
 
154
  -d '{
155
  "videoBase64": "'$(base64 -i input.mp4)'",
156
  "key": "my-secret",
@@ -221,8 +223,8 @@ npx tsx server/cli.ts presets
221
  </thead>
222
  <tbody>
223
  {[
224
- { name: 'Light', delta: 50, bch: '(63,36,5)', mask: 'No', use: 'Near-invisible, mild compression' },
225
- { name: 'Moderate', delta: 80, bch: '(63,36,5)', mask: 'Yes', use: 'Balanced with perceptual masking' },
226
  { name: 'Strong', delta: 110, bch: '(63,36,5)', mask: 'Yes', use: 'More frequencies, handles rescaling' },
227
  { name: 'Fortress', delta: 150, bch: '(63,36,5)', mask: 'Yes', use: 'Maximum robustness' },
228
  ].map((p) => (
@@ -238,40 +240,6 @@ npx tsx server/cli.ts presets
238
  </table>
239
  </div>
240
  </Section>
241
-
242
- {/* How it works */}
243
- <Section title="How It Works">
244
- <div className="space-y-3 text-xs text-zinc-400">
245
- <div className="rounded-lg bg-zinc-900/50 p-3 ring-1 ring-zinc-800/50">
246
- <p className="mb-2 font-medium text-zinc-300">Embedding pipeline</p>
247
- <code className="block leading-relaxed text-zinc-400">
248
- Y plane → 2-level Haar DWT → HL subband → tile grid →<br />
249
- per tile: 8x8 DCT → select mid-freq coefficients →<br />
250
- DM-QIM embed coded bits → inverse DCT → inverse DWT
251
- </code>
252
- </div>
253
- <div className="rounded-lg bg-zinc-900/50 p-3 ring-1 ring-zinc-800/50">
254
- <p className="mb-2 font-medium text-zinc-300">Payload encoding</p>
255
- <code className="block leading-relaxed text-zinc-400">
256
- 32-bit payload → CRC append → BCH encode → keyed interleave → map to coefficients
257
- </code>
258
- </div>
259
- <div className="rounded-lg bg-zinc-900/50 p-3 ring-1 ring-zinc-800/50">
260
- <p className="mb-2 font-medium text-zinc-300">Detection</p>
261
- <code className="block leading-relaxed text-zinc-400">
262
- Y plane → DWT → HL subband → tile grid →<br />
263
- per tile: DCT → DM-QIM soft extract →<br />
264
- soft-combine across tiles + frames → BCH decode → CRC verify
265
- </code>
266
- </div>
267
- <p>
268
- The watermark is embedded exclusively in the <strong className="text-zinc-300">luminance (Y) channel</strong> within
269
- the DWT-domain DCT coefficients. This makes it invisible to the human eye while surviving
270
- lossy compression, rescaling, and color adjustments. Each tile carries a complete copy of
271
- the payload for redundancy — more tiles means better detection under degradation.
272
- </p>
273
- </div>
274
- </Section>
275
  </div>
276
  );
277
  }
 
136
  {/* HTTP API */}
137
  <Section title="HTTP API">
138
  <p className="text-xs text-zinc-400">
139
+ Base URL: <code className="text-zinc-300">https://lightricks-ltmarx.hf.space</code> (HF Space) or <code className="text-zinc-300">http://localhost:7860</code> (local).
140
+ For private Spaces, add <code className="text-zinc-300">Authorization: Bearer hf_YOUR_TOKEN</code> header.
141
  </p>
142
 
143
  <div className="space-y-4">
 
150
  { name: 'payload', type: 'string', desc: 'Hex string, up to 8 chars (32 bits)', required: true },
151
  ]} />
152
  <CodeBlock lang="bash">{`
153
+ curl -X POST https://lightricks-ltmarx.hf.space/api/embed \\
154
  -H "Content-Type: application/json" \\
155
+ -H "Authorization: Bearer hf_YOUR_TOKEN" \\
156
  -d '{
157
  "videoBase64": "'$(base64 -i input.mp4)'",
158
  "key": "my-secret",
 
223
  </thead>
224
  <tbody>
225
  {[
226
+ { name: 'Light', delta: 50, bch: '(63,36,5)', mask: 'Yes', use: 'Near-invisible, mild compression' },
227
+ { name: 'Moderate', delta: 62, bch: '(63,36,5)', mask: 'Yes', use: 'Near-invisible with perceptual masking' },
228
  { name: 'Strong', delta: 110, bch: '(63,36,5)', mask: 'Yes', use: 'More frequencies, handles rescaling' },
229
  { name: 'Fortress', delta: 150, bch: '(63,36,5)', mask: 'Yes', use: 'Maximum robustness' },
230
  ].map((p) => (
 
240
  </table>
241
  </div>
242
  </Section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  </div>
244
  );
245
  }
web/src/components/EmbedPanel.tsx CHANGED
@@ -27,8 +27,8 @@ export default function EmbedPanel() {
27
  const [videoName, setVideoName] = useState('');
28
  const [key, setKey] = useState('');
29
  const [payload, setPayload] = useState('DEADBEEF');
30
- const [preset, setPreset] = useState<PresetName>('moderate');
31
- const [alpha, setAlpha] = useState(0.33);
32
  const [processing, setProcessing] = useState(false);
33
  const [progress, setProgress] = useState({ phase: '', current: 0, total: 0 });
34
  const [jokeIndex, setJokeIndex] = useState(0);
 
27
  const [videoName, setVideoName] = useState('');
28
  const [key, setKey] = useState('');
29
  const [payload, setPayload] = useState('DEADBEEF');
30
+ const [preset, setPreset] = useState<PresetName>('light');
31
+ const [alpha, setAlpha] = useState(0.00);
32
  const [processing, setProcessing] = useState(false);
33
  const [progress, setProgress] = useState({ phase: '', current: 0, total: 0 });
34
  const [jokeIndex, setJokeIndex] = useState(0);
web/src/components/HowItWorks.tsx ADDED
@@ -0,0 +1,944 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState, useCallback } from 'react';
2
+ import * as d3 from 'd3';
3
+
4
+ interface Stage {
5
+ title: string;
6
+ description: string;
7
+ explanation: string[];
8
+ draw: (svg: d3.Selection<SVGGElement, unknown, null, undefined>, w: number, h: number) => void;
9
+ }
10
+
11
+ // ── Color tokens ──────────────────────────────────────────────
12
+ const C = {
13
+ bg: '#09090b', // zinc-950
14
+ surface: '#18181b', // zinc-900
15
+ border: '#27272a', // zinc-800
16
+ muted: '#3f3f46', // zinc-700
17
+ dim: '#52525b', // zinc-600
18
+ text: '#a1a1aa', // zinc-400
19
+ bright: '#e4e4e7', // zinc-200
20
+ white: '#fafafa', // zinc-50
21
+ blue: '#3b82f6',
22
+ violet: '#8b5cf6',
23
+ amber: '#f59e0b',
24
+ emerald: '#10b981',
25
+ red: '#ef4444',
26
+ };
27
+
28
+ // ── Shared drawing helpers ────────────────────────────────────
29
+ function arrow(
30
+ g: d3.Selection<SVGGElement, unknown, null, undefined>,
31
+ x1: number, y1: number, x2: number, y2: number,
32
+ color = C.text, strokeWidth = 1.5,
33
+ ) {
34
+ const id = `ah-${Math.random().toString(36).slice(2, 8)}`;
35
+ g.append('defs').append('marker')
36
+ .attr('id', id)
37
+ .attr('viewBox', '0 0 10 10')
38
+ .attr('refX', 9).attr('refY', 5)
39
+ .attr('markerWidth', 6).attr('markerHeight', 6)
40
+ .attr('orient', 'auto-start-reverse')
41
+ .append('path').attr('d', 'M 0 0 L 10 5 L 0 10 z').attr('fill', color);
42
+
43
+ g.append('line')
44
+ .attr('x1', x1).attr('y1', y1).attr('x2', x2).attr('y2', y2)
45
+ .attr('stroke', color).attr('stroke-width', strokeWidth)
46
+ .attr('marker-end', `url(#${id})`);
47
+ }
48
+
49
+ function labelText(
50
+ g: d3.Selection<SVGGElement, unknown, null, undefined>,
51
+ x: number, y: number, text: string,
52
+ opts: { size?: number; color?: string; anchor?: string; weight?: string } = {},
53
+ ) {
54
+ g.append('text')
55
+ .attr('x', x).attr('y', y)
56
+ .attr('text-anchor', opts.anchor ?? 'middle')
57
+ .attr('fill', opts.color ?? C.text)
58
+ .attr('font-size', opts.size ?? 11)
59
+ .attr('font-family', 'ui-monospace, monospace')
60
+ .attr('font-weight', opts.weight ?? '400')
61
+ .text(text);
62
+ }
63
+
64
+ function roundedRect(
65
+ g: d3.Selection<SVGGElement, unknown, null, undefined>,
66
+ x: number, y: number, w: number, h: number,
67
+ opts: { fill?: string; stroke?: string; rx?: number; opacity?: number } = {},
68
+ ) {
69
+ g.append('rect')
70
+ .attr('x', x).attr('y', y).attr('width', w).attr('height', h)
71
+ .attr('rx', opts.rx ?? 4)
72
+ .attr('fill', opts.fill ?? 'none')
73
+ .attr('stroke', opts.stroke ?? C.border)
74
+ .attr('stroke-width', 1)
75
+ .attr('opacity', opts.opacity ?? 1);
76
+ }
77
+
78
+ // ── Stage definitions ─────────────────────────────────────────
79
+
80
+ const stages: Stage[] = [
81
+
82
+ // ──────────── 1. Input Y Plane ────────────
83
+ {
84
+ title: 'Input Y Plane',
85
+ description: 'Luminance extraction from video frame',
86
+ explanation: [
87
+ 'Each video frame is converted from RGB to YCbCr color space. We extract only the Y (luminance) channel — the brightness of each pixel, ignoring color information.',
88
+ 'Human vision is far more sensitive to luminance than to chrominance. By embedding exclusively in the Y channel, we can exploit this: small changes to brightness values are imperceptible, while the watermark survives color adjustments and chroma subsampling during compression.',
89
+ ],
90
+ draw(g, w, h) {
91
+ const gridSize = 10;
92
+ const cellSize = 28;
93
+ const offsetX = (w - gridSize * cellSize) / 2;
94
+ const offsetY = (h - gridSize * cellSize) / 2 + 10;
95
+
96
+ roundedRect(g, offsetX - 16, offsetY - 16, gridSize * cellSize + 32, gridSize * cellSize + 32, {
97
+ stroke: C.muted, rx: 8, fill: C.surface + '80',
98
+ });
99
+
100
+ const rng = d3.randomLcg(42);
101
+ for (let r = 0; r < gridSize; r++) {
102
+ for (let c = 0; c < gridSize; c++) {
103
+ const val = Math.floor(rng() * 220 + 20);
104
+ const lum = d3.interpolateGreys(1 - val / 255);
105
+ g.append('rect')
106
+ .attr('x', offsetX + c * cellSize)
107
+ .attr('y', offsetY + r * cellSize)
108
+ .attr('width', cellSize - 1).attr('height', cellSize - 1)
109
+ .attr('fill', lum).attr('rx', 2);
110
+
111
+ if ((r + c) % 3 === 0) {
112
+ g.append('text')
113
+ .attr('x', offsetX + c * cellSize + cellSize / 2)
114
+ .attr('y', offsetY + r * cellSize + cellSize / 2 + 4)
115
+ .attr('text-anchor', 'middle')
116
+ .attr('fill', val > 140 ? '#000' : '#fff')
117
+ .attr('font-size', 8)
118
+ .attr('font-family', 'ui-monospace, monospace')
119
+ .attr('opacity', 0.7)
120
+ .text(val);
121
+ }
122
+ }
123
+ }
124
+
125
+ labelText(g, w / 2, offsetY - 28, 'Y (luminance) channel', { color: C.bright, size: 13, weight: '600' });
126
+
127
+ // RGB stack → arrow → Y plane
128
+ const arrowG = g.append('g');
129
+ const frameLeft = offsetX - 50;
130
+ roundedRect(arrowG, frameLeft - 30, h / 2 - 20, 24, 18, { fill: C.blue + '20', stroke: C.blue + '50', rx: 3 });
131
+ roundedRect(arrowG, frameLeft - 28, h / 2 - 18, 24, 18, { fill: C.violet + '20', stroke: C.violet + '50', rx: 3 });
132
+ roundedRect(arrowG, frameLeft - 26, h / 2 - 16, 24, 18, { fill: C.emerald + '20', stroke: C.emerald + '50', rx: 3 });
133
+ labelText(arrowG, frameLeft - 14, h / 2 + 20, 'RGB', { color: C.dim, size: 9 });
134
+ arrow(arrowG, frameLeft + 4, h / 2 - 6, offsetX - 20, h / 2 - 6, C.blue);
135
+ },
136
+ },
137
+
138
+ // ──────────── 2. Haar DWT ────────────
139
+ {
140
+ title: 'Haar DWT',
141
+ description: '2-level Haar discrete wavelet transform',
142
+ explanation: [
143
+ 'The Haar wavelet transform decomposes the Y plane into frequency subbands. Each level splits the image into four quadrants: LL (approximation), LH (vertical detail), HL (horizontal detail), and HH (diagonal detail). We apply this twice, recursively subdividing the LL quadrant.',
144
+ 'We embed in the HL (horizontal detail) subband at the deepest decomposition level. HL captures horizontal edge and texture energy — changes here are masked by the image content itself. The deepest level provides maximum downsampling, concentrating energy so that each modified coefficient affects a larger spatial region, improving robustness against compression and rescaling.',
145
+ 'LH or HH could also work, but HL empirically gives the best balance of imperceptibility and robustness for typical video content with horizontal motion.',
146
+ ],
147
+ draw(g, w, h) {
148
+ const size = 260;
149
+ const ox = (w - size) / 2;
150
+ const oy = (h - size) / 2 + 8;
151
+ const half = size / 2;
152
+ const quarter = size / 4;
153
+
154
+ // Glow filter (must be defined before use)
155
+ const defs = g.append('defs');
156
+ const filter = defs.append('filter').attr('id', 'glow');
157
+ filter.append('feGaussianBlur').attr('stdDeviation', 4).attr('result', 'coloredBlur');
158
+ const merge = filter.append('feMerge');
159
+ merge.append('feMergeNode').attr('in', 'coloredBlur');
160
+ merge.append('feMergeNode').attr('in', 'SourceGraphic');
161
+
162
+ // Full square
163
+ g.append('rect')
164
+ .attr('x', ox).attr('y', oy).attr('width', size).attr('height', size)
165
+ .attr('fill', C.surface).attr('stroke', C.border).attr('stroke-width', 1.5).attr('rx', 4);
166
+
167
+ // First level quadrants
168
+ const quads = [
169
+ { x: 0, y: 0, w: half, h: half, label: '', fill: C.muted + '30' },
170
+ { x: half, y: 0, w: half, h: half, label: 'HL₁', fill: C.blue + '15', sub: 'horiz. detail' },
171
+ { x: 0, y: half, w: half, h: half, label: 'LH₁', fill: C.muted + '20', sub: 'vert. detail' },
172
+ { x: half, y: half, w: half, h: half, label: 'HH₁', fill: C.muted + '15', sub: 'diagonal' },
173
+ ];
174
+
175
+ quads.forEach(q => {
176
+ g.append('rect')
177
+ .attr('x', ox + q.x).attr('y', oy + q.y)
178
+ .attr('width', q.w).attr('height', q.h)
179
+ .attr('fill', q.fill).attr('stroke', C.border);
180
+ if (q.label) {
181
+ labelText(g, ox + q.x + q.w / 2, oy + q.y + q.h / 2 - 2, q.label, { color: C.dim, size: 12 });
182
+ labelText(g, ox + q.x + q.w / 2, oy + q.y + q.h / 2 + 12, (q as { sub?: string }).sub ?? '', { color: C.muted, size: 8 });
183
+ }
184
+ });
185
+
186
+ // Second level (subdivide LL₁)
187
+ const sub = [
188
+ { x: 0, y: 0, w: quarter, h: quarter, label: 'LL₂', fill: C.muted + '40', sub: 'approx.' },
189
+ { x: quarter, y: 0, w: quarter, h: quarter, label: 'HL₂', fill: C.blue + '25', sub: '' },
190
+ { x: 0, y: quarter, w: quarter, h: quarter, label: 'LH₂', fill: C.muted + '25', sub: '' },
191
+ { x: quarter, y: quarter, w: quarter, h: quarter, label: 'HH₂', fill: C.muted + '20', sub: '' },
192
+ ];
193
+
194
+ sub.forEach(q => {
195
+ g.append('rect')
196
+ .attr('x', ox + q.x).attr('y', oy + q.y)
197
+ .attr('width', q.w).attr('height', q.h)
198
+ .attr('fill', q.fill).attr('stroke', C.border);
199
+ labelText(g, ox + q.x + q.w / 2, oy + q.y + q.h / 2 + 4, q.label, {
200
+ color: q.label.startsWith('HL') ? C.blue : C.dim, size: 10,
201
+ });
202
+ });
203
+
204
+ // Highlight HL subbands with glow
205
+ [{ x: half, y: 0, w: half, h: half }, { x: quarter, y: 0, w: quarter, h: quarter }].forEach(hl => {
206
+ g.append('rect')
207
+ .attr('x', ox + hl.x).attr('y', oy + hl.y)
208
+ .attr('width', hl.w).attr('height', hl.h)
209
+ .attr('fill', 'none').attr('stroke', C.blue)
210
+ .attr('stroke-width', 2.5)
211
+ .attr('filter', 'url(#glow)');
212
+ });
213
+
214
+ // Annotations
215
+ labelText(g, ox + size + 14, oy + quarter / 2, 'embed', { color: C.blue, size: 10, anchor: 'start', weight: '600' });
216
+ labelText(g, ox + size + 14, oy + quarter / 2 + 13, 'here', { color: C.blue, size: 10, anchor: 'start', weight: '600' });
217
+ arrow(g, ox + size + 10, oy + quarter / 2 + 2, ox + half + half + 4, oy + quarter / 2 + 2, C.blue + '60');
218
+
219
+ labelText(g, w / 2, oy - 16, 'Wavelet decomposition (2 levels)', { color: C.bright, size: 13, weight: '600' });
220
+
221
+ // Level annotations on left
222
+ labelText(g, ox - 10, oy + quarter / 2 + 4, 'L2', { color: C.dim, size: 9, anchor: 'end' });
223
+ labelText(g, ox - 10, oy + half + half / 2 + 4, 'L1', { color: C.dim, size: 9, anchor: 'end' });
224
+ },
225
+ },
226
+
227
+ // ──────────── 3. Tile Grid ────────────
228
+ {
229
+ title: 'Tile Grid',
230
+ description: 'Periodic tile overlay for redundancy',
231
+ explanation: [
232
+ 'The HL subband is partitioned into a periodic grid of equally-sized tiles. Each tile independently carries a complete copy of the entire coded payload. This is the core redundancy mechanism.',
233
+ 'If parts of the frame are cropped or heavily damaged, the surviving tiles still contain the full message. During detection, soft-bit estimates from all tiles are averaged together — more tiles means a stronger signal. For a typical 1080p frame with the "moderate" preset, this yields ~24 tiles with ~37x bit replication per tile.',
234
+ ],
235
+ draw(g, w, h) {
236
+ const rectW = 420;
237
+ const rectH = 220;
238
+ const ox = (w - rectW) / 2;
239
+ const oy = (h - rectH) / 2 + 4;
240
+ const tileW = 70;
241
+ const tileH = 55;
242
+ const cols = Math.floor(rectW / tileW);
243
+ const rows = Math.floor(rectH / tileH);
244
+
245
+ // HL subband background
246
+ g.append('rect')
247
+ .attr('x', ox).attr('y', oy).attr('width', rectW).attr('height', rectH)
248
+ .attr('fill', C.blue + '08').attr('stroke', C.blue + '40').attr('stroke-width', 1.5).attr('rx', 4);
249
+
250
+ labelText(g, w / 2, oy - 18, 'HL subband tile partitioning', { color: C.bright, size: 13, weight: '600' });
251
+
252
+ for (let r = 0; r < rows; r++) {
253
+ for (let c = 0; c < cols; c++) {
254
+ const tx = ox + c * tileW;
255
+ const ty = oy + r * tileH;
256
+ const isFilled = (r + c) % 2 === 0;
257
+
258
+ g.append('rect')
259
+ .attr('x', tx + 1).attr('y', ty + 1)
260
+ .attr('width', tileW - 2).attr('height', tileH - 2)
261
+ .attr('fill', isFilled ? C.violet + '12' : 'none')
262
+ .attr('stroke', C.muted)
263
+ .attr('stroke-dasharray', '3,3')
264
+ .attr('rx', 2);
265
+
266
+ if (isFilled) {
267
+ labelText(g, tx + tileW / 2, ty + tileH / 2 + 3, 'P', {
268
+ color: C.violet + '60', size: 16, weight: '700',
269
+ });
270
+ }
271
+ }
272
+ }
273
+
274
+ // Legend — positioned clearly below with no overlap
275
+ const ly = oy + rectH + 16;
276
+ g.append('rect').attr('x', w / 2 - 140).attr('y', ly - 5).attr('width', 12).attr('height', 12)
277
+ .attr('fill', C.violet + '12').attr('stroke', C.muted).attr('rx', 2);
278
+ labelText(g, w / 2 - 122, ly + 5, 'P', { color: C.violet + '60', size: 10, anchor: 'start', weight: '700' });
279
+ labelText(g, w / 2 - 110, ly + 5, '= full payload copy', { color: C.dim, size: 10, anchor: 'start' });
280
+
281
+ labelText(g, w / 2 + 60, ly + 5, `${cols * rows} tiles`, { color: C.text, size: 10, anchor: 'start' });
282
+ },
283
+ },
284
+
285
+ // ──────────── 4. 8×8 DCT Blocks ────────────
286
+ {
287
+ title: '8×8 DCT Blocks',
288
+ description: 'Block DCT frequency decomposition',
289
+ explanation: [
290
+ 'Within each tile, the subband coefficients are divided into non-overlapping 8x8 blocks — the same block size used by JPEG and H.264. Each block is transformed from the spatial domain into the frequency domain using the Discrete Cosine Transform (DCT).',
291
+ 'The DCT concentrates energy into a few low-frequency coefficients (top-left), while mid and high frequencies (bottom-right) carry texture detail. We embed watermark bits into mid-frequency coefficients — they carry enough energy to survive lossy compression, but are perceptually less salient than the dominant low-frequency components.',
292
+ ],
293
+ draw(g, w, h) {
294
+ const tileSize = 190;
295
+ const ox = w / 2 - tileSize - 30;
296
+ const oy = (h - tileSize) / 2 + 14;
297
+ const blockCount = 5;
298
+ const blockSize = tileSize / blockCount;
299
+
300
+ labelText(g, w / 2, oy - 20, 'One tile → 8x8 block grid → DCT', { color: C.bright, size: 13, weight: '600' });
301
+
302
+ g.append('rect')
303
+ .attr('x', ox).attr('y', oy).attr('width', tileSize).attr('height', tileSize)
304
+ .attr('fill', C.surface).attr('stroke', C.border).attr('rx', 4);
305
+
306
+ const highlightR = 1, highlightC = 2;
307
+ for (let r = 0; r < blockCount; r++) {
308
+ for (let c = 0; c < blockCount; c++) {
309
+ const isHighlight = r === highlightR && c === highlightC;
310
+ g.append('rect')
311
+ .attr('x', ox + c * blockSize + 1)
312
+ .attr('y', oy + r * blockSize + 1)
313
+ .attr('width', blockSize - 2).attr('height', blockSize - 2)
314
+ .attr('fill', isHighlight ? C.amber + '25' : 'none')
315
+ .attr('stroke', isHighlight ? C.amber : C.muted)
316
+ .attr('stroke-width', isHighlight ? 2 : 0.5)
317
+ .attr('rx', 1);
318
+ }
319
+ }
320
+
321
+ // Arrow to DCT heatmap
322
+ const dctOx = w / 2 + 40;
323
+ const dctOy = oy + 16;
324
+ const dctSize = 160;
325
+ arrow(g, ox + (highlightC + 1) * blockSize + 8, oy + (highlightR + 0.5) * blockSize, dctOx - 10, dctOy + dctSize / 2, C.amber);
326
+
327
+ // Frequency-domain heatmap
328
+ const cells = 8;
329
+ const cs = dctSize / cells;
330
+ g.append('rect')
331
+ .attr('x', dctOx - 2).attr('y', dctOy - 2)
332
+ .attr('width', dctSize + 4).attr('height', dctSize + 4)
333
+ .attr('fill', 'none').attr('stroke', C.amber + '60').attr('rx', 4);
334
+
335
+ const colorScale = d3.scaleSequential(d3.interpolateInferno).domain([0, 12]);
336
+ for (let r = 0; r < cells; r++) {
337
+ for (let c = 0; c < cells; c++) {
338
+ const energy = Math.max(0, 10 - (r + c) * 0.9 + (Math.sin(r * c) * 1.5));
339
+ g.append('rect')
340
+ .attr('x', dctOx + c * cs).attr('y', dctOy + r * cs)
341
+ .attr('width', cs - 1).attr('height', cs - 1)
342
+ .attr('fill', colorScale(energy)).attr('rx', 1);
343
+ }
344
+ }
345
+
346
+ // Frequency labels
347
+ labelText(g, dctOx + dctSize / 2, dctOy + dctSize + 18, 'DCT coefficients', { color: C.amber, size: 10 });
348
+ labelText(g, dctOx - 8, dctOy + 8, 'DC', { color: C.dim, size: 8, anchor: 'end' });
349
+ labelText(g, dctOx + dctSize + 4, dctOy + dctSize - 4, 'HF', { color: C.dim, size: 8, anchor: 'start' });
350
+ arrow(g, dctOx + dctSize * 0.15, dctOy + dctSize + 28, dctOx + dctSize * 0.85, dctOy + dctSize + 28, C.dim, 1);
351
+ labelText(g, dctOx + dctSize / 2, dctOy + dctSize + 40, 'increasing frequency →', { color: C.muted, size: 8 });
352
+
353
+ labelText(g, ox + tileSize / 2, oy + tileSize + 18, 'spatial domain', { color: C.dim, size: 9 });
354
+ },
355
+ },
356
+
357
+ // ──────────── 5. Coefficient Selection + Masking ────────────
358
+ {
359
+ title: 'Coefficient Selection',
360
+ description: 'Zigzag scan with perceptual masking',
361
+ explanation: [
362
+ 'DCT coefficients are traversed in zigzag order — a diagonal path from low to high frequency. We select mid-frequency positions (indices ~10-25): low enough to survive quantization in lossy codecs, high enough to avoid visible artifacts in flat areas.',
363
+ 'Each block also gets a perceptual masking factor based on its AC energy relative to the tile median, using a square-root curve. Flat/smooth blocks (low energy) are barely embedded at all (factor as low as 0.1x) — any modification there would be visible. Textured and noisy blocks tolerate much stronger embedding (up to 2.0x). No blocks are skipped entirely, but flat areas contribute almost nothing while busy areas carry the bulk of the watermark signal.',
364
+ ],
365
+ draw(g, w, h) {
366
+ const cells = 8;
367
+ const cellSize = 30;
368
+ const gridSize = cells * cellSize;
369
+ const ox = (w - gridSize) / 2 - 80;
370
+ const oy = (h - gridSize) / 2 + 14;
371
+
372
+ labelText(g, w / 2, oy - 20, 'Zigzag scan + perceptual masking', { color: C.bright, size: 13, weight: '600' });
373
+
374
+ // Zigzag order
375
+ const zigzag: [number, number][] = [];
376
+ for (let sum = 0; sum < 2 * cells - 1; sum++) {
377
+ if (sum % 2 === 0) {
378
+ for (let r = Math.min(sum, cells - 1); r >= Math.max(0, sum - cells + 1); r--) {
379
+ zigzag.push([r, sum - r]);
380
+ }
381
+ } else {
382
+ for (let r = Math.max(0, sum - cells + 1); r <= Math.min(sum, cells - 1); r++) {
383
+ zigzag.push([r, sum - r]);
384
+ }
385
+ }
386
+ }
387
+
388
+ const midStart = 10, midEnd = 26;
389
+ zigzag.forEach(([r, c], idx) => {
390
+ const isMid = idx >= midStart && idx < midEnd;
391
+ const isDc = idx === 0;
392
+ const isLow = idx > 0 && idx < midStart;
393
+ g.append('rect')
394
+ .attr('x', ox + c * cellSize + 1)
395
+ .attr('y', oy + r * cellSize + 1)
396
+ .attr('width', cellSize - 2).attr('height', cellSize - 2)
397
+ .attr('fill', isMid ? C.amber + '30' : isDc ? C.blue + '20' : isLow ? C.blue + '08' : C.surface)
398
+ .attr('stroke', isMid ? C.amber + '80' : C.muted)
399
+ .attr('stroke-width', isMid ? 1.5 : 0.5)
400
+ .attr('rx', 2);
401
+
402
+ if (isMid || idx < 4 || idx > 60) {
403
+ g.append('text')
404
+ .attr('x', ox + c * cellSize + cellSize / 2)
405
+ .attr('y', oy + r * cellSize + cellSize / 2 + 3.5)
406
+ .attr('text-anchor', 'middle')
407
+ .attr('fill', isMid ? C.amber : C.dim)
408
+ .attr('font-size', 8)
409
+ .attr('font-family', 'ui-monospace, monospace')
410
+ .text(idx);
411
+ }
412
+ });
413
+
414
+ // Zigzag path
415
+ const pathPoints = zigzag.slice(0, 30).map(([r, c]) => [
416
+ ox + c * cellSize + cellSize / 2,
417
+ oy + r * cellSize + cellSize / 2,
418
+ ] as [number, number]);
419
+
420
+ g.append('path')
421
+ .attr('d', d3.line().curve(d3.curveLinear)(pathPoints)!)
422
+ .attr('fill', 'none')
423
+ .attr('stroke', C.text + '40')
424
+ .attr('stroke-width', 1)
425
+ .attr('stroke-dasharray', '3,2');
426
+
427
+ // Legend below zigzag grid
428
+ const ly = oy + gridSize + 14;
429
+ g.append('rect').attr('x', ox).attr('y', ly - 4).attr('width', 12).attr('height', 12)
430
+ .attr('fill', C.amber + '30').attr('stroke', C.amber + '80').attr('rx', 2);
431
+ labelText(g, ox + 16, ly + 6, `positions ${midStart}–${midEnd - 1}`, { color: C.amber, size: 9, anchor: 'start' });
432
+
433
+ // Masking diagram on the right
434
+ const mx = ox + gridSize + 40;
435
+ const my = oy + 10;
436
+ const barW = 24;
437
+ const barGap = 6;
438
+ const maxBarH = 120;
439
+
440
+ labelText(g, mx + 70, my - 4, 'masking factor', { color: C.bright, size: 10, weight: '600' });
441
+
442
+ const blockTypes = [
443
+ { label: 'flat', energy: 0.01, factor: 0.1, color: C.red },
444
+ { label: 'smooth', energy: 0.25, factor: 0.5, color: C.blue },
445
+ { label: 'texture', energy: 1.0, factor: 1.0, color: C.emerald },
446
+ { label: 'noisy', energy: 4.0, factor: 2.0, color: C.amber },
447
+ ];
448
+
449
+ blockTypes.forEach((bt, i) => {
450
+ const bx = mx + i * (barW + barGap);
451
+ const barH = (bt.factor / 2.0) * maxBarH;
452
+ const by = my + 16 + maxBarH - barH;
453
+
454
+ // Bar
455
+ g.append('rect')
456
+ .attr('x', bx).attr('y', by)
457
+ .attr('width', barW).attr('height', barH)
458
+ .attr('fill', bt.color + '30')
459
+ .attr('stroke', bt.color + '80')
460
+ .attr('rx', 3);
461
+
462
+ // Factor label
463
+ labelText(g, bx + barW / 2, by - 6, `${bt.factor}x`, { color: bt.color, size: 9, weight: '600' });
464
+
465
+ // Block type label
466
+ labelText(g, bx + barW / 2, my + 16 + maxBarH + 14, bt.label, { color: C.dim, size: 8 });
467
+ });
468
+
469
+ // Baseline at 1.0x
470
+ const baselineY = my + 16 + maxBarH - (1.0 / 2.0) * maxBarH;
471
+ g.append('line')
472
+ .attr('x1', mx - 4).attr('y1', baselineY)
473
+ .attr('x2', mx + blockTypes.length * (barW + barGap)).attr('y2', baselineY)
474
+ .attr('stroke', C.dim).attr('stroke-dasharray', '4,3').attr('stroke-width', 1);
475
+ labelText(g, mx + blockTypes.length * (barW + barGap) + 4, baselineY + 4, '1.0x', { color: C.dim, size: 8, anchor: 'start' });
476
+
477
+ labelText(g, mx + 70, my + 16 + maxBarH + 30, 'Δ_eff = Δ × factor', { color: C.muted, size: 9 });
478
+ },
479
+ },
480
+
481
+ // ──────────── 6. DM-QIM Embedding ────────────
482
+ {
483
+ title: 'DM-QIM Embedding',
484
+ description: 'Dithered quantization index modulation',
485
+ explanation: [
486
+ 'To embed a single bit into a DCT coefficient, DM-QIM defines two interleaved quantization lattices spaced Δ apart. The lattice for bit=0 has points at even multiples of Δ (…, -2Δ, 0, 2Δ, …). The lattice for bit=1 is shifted by Δ/2 (…, -Δ/2, Δ/2, 3Δ/2, …).',
487
+ 'To embed: subtract a secret dither value d, quantize the shifted coefficient to the nearest point on the target bit\'s lattice, then add d back. The dither is derived from the secret key — without it, an attacker cannot determine which lattice a coefficient belongs to.',
488
+ 'During detection, we subtract d and measure which lattice the coefficient is closer to. The signed distance gives a "soft bit" — its magnitude indicates confidence. Larger Δ means more robust (bigger lattice spacing) but more visible distortion.',
489
+ ],
490
+ draw(g, w, h) {
491
+ const lineY = h / 2 - 15;
492
+ const lineLeft = 70;
493
+ const lineRight = w - 70;
494
+ const lineW = lineRight - lineLeft;
495
+
496
+ labelText(g, w / 2, 30, 'Two interleaved quantization lattices', { color: C.bright, size: 13, weight: '600' });
497
+
498
+ // Number line
499
+ g.append('line')
500
+ .attr('x1', lineLeft - 10).attr('y1', lineY)
501
+ .attr('x2', lineRight + 10).attr('y2', lineY)
502
+ .attr('stroke', C.muted).attr('stroke-width', 1.5);
503
+
504
+ // Lattice points — bit 0 (even multiples of Δ)
505
+ const delta = lineW / 6;
506
+ const lattice0: number[] = [];
507
+ const lattice1: number[] = [];
508
+
509
+ for (let i = 0; i <= 6; i++) {
510
+ const x = lineLeft + i * delta;
511
+ if (i % 2 === 0) {
512
+ lattice0.push(x);
513
+ // Bit=0 markers (triangles pointing up)
514
+ g.append('path')
515
+ .attr('d', `M${x - 5},${lineY + 4} L${x},${lineY - 6} L${x + 5},${lineY + 4}Z`)
516
+ .attr('fill', C.blue + '60').attr('stroke', C.blue).attr('stroke-width', 1.5);
517
+ } else {
518
+ lattice1.push(x);
519
+ // Bit=1 markers (circles)
520
+ g.append('circle')
521
+ .attr('cx', x).attr('cy', lineY)
522
+ .attr('r', 5)
523
+ .attr('fill', C.emerald + '40').attr('stroke', C.emerald).attr('stroke-width', 1.5);
524
+ }
525
+ }
526
+
527
+ // Δ bracket between first two points
528
+ const bY = lineY + 20;
529
+ g.append('line').attr('x1', lineLeft).attr('y1', bY).attr('x2', lineLeft + delta).attr('y2', bY)
530
+ .attr('stroke', C.text).attr('stroke-width', 1);
531
+ g.append('line').attr('x1', lineLeft).attr('y1', bY - 4).attr('x2', lineLeft).attr('y2', bY + 4)
532
+ .attr('stroke', C.text).attr('stroke-width', 1);
533
+ g.append('line').attr('x1', lineLeft + delta).attr('y1', bY - 4).attr('x2', lineLeft + delta).attr('y2', bY + 4)
534
+ .attr('stroke', C.text).attr('stroke-width', 1);
535
+ labelText(g, lineLeft + delta / 2, bY + 14, 'Δ', { color: C.bright, size: 11, weight: '600' });
536
+
537
+ // Δ/2 bracket
538
+ g.append('line').attr('x1', lineLeft).attr('y1', bY + 28).attr('x2', lineLeft + delta / 2).attr('y2', bY + 28)
539
+ .attr('stroke', C.dim).attr('stroke-width', 1);
540
+ g.append('line').attr('x1', lineLeft).attr('y1', bY + 24).attr('x2', lineLeft).attr('y2', bY + 32)
541
+ .attr('stroke', C.dim).attr('stroke-width', 1);
542
+ g.append('line').attr('x1', lineLeft + delta / 2).attr('y1', bY + 24).attr('x2', lineLeft + delta / 2).attr('y2', bY + 32)
543
+ .attr('stroke', C.dim).attr('stroke-width', 1);
544
+ labelText(g, lineLeft + delta / 4, bY + 42, 'Δ/2', { color: C.dim, size: 10 });
545
+
546
+ // Original coefficient and snap animation
547
+ const origX = lineLeft + 2.3 * delta;
548
+ const snapTarget = lineLeft + 2 * delta; // snap to nearest bit=0 point (even)
549
+ const snapTarget1 = lineLeft + 3 * delta; // or bit=1 point (odd)
550
+
551
+ // Original value
552
+ g.append('line')
553
+ .attr('x1', origX).attr('y1', lineY - 40)
554
+ .attr('x2', origX).attr('y2', lineY - 10)
555
+ .attr('stroke', C.amber).attr('stroke-width', 1.5)
556
+ .attr('stroke-dasharray', '3,2');
557
+ g.append('circle')
558
+ .attr('cx', origX).attr('cy', lineY - 44)
559
+ .attr('r', 3).attr('fill', C.amber);
560
+ labelText(g, origX, lineY - 54, 'c (original)', { color: C.amber, size: 9 });
561
+
562
+ // Snap arrows — show both options
563
+ // To bit=0
564
+ arrow(g, origX - 4, lineY - 28, snapTarget + 6, lineY - 10, C.blue, 1.5);
565
+ labelText(g, (origX + snapTarget) / 2 - 14, lineY - 36, 'if bit=0', { color: C.blue, size: 8 });
566
+
567
+ // To bit=1
568
+ arrow(g, origX + 4, lineY - 28, snapTarget1 - 6, lineY - 10, C.emerald, 1.5);
569
+ labelText(g, (origX + snapTarget1) / 2 + 14, lineY - 36, 'if bit=1', { color: C.emerald, size: 8 });
570
+
571
+ // Legend
572
+ const ly = lineY + 62;
573
+ // Bit 0
574
+ g.append('path')
575
+ .attr('d', `M${w / 2 - 120 - 5},${ly + 4} L${w / 2 - 120},${ly - 4} L${w / 2 - 115},${ly + 4}Z`)
576
+ .attr('fill', C.blue + '60').attr('stroke', C.blue).attr('stroke-width', 1);
577
+ labelText(g, w / 2 - 105, ly + 3, 'bit = 0 lattice (even·Δ)', { color: C.blue, size: 9, anchor: 'start' });
578
+ // Bit 1
579
+ g.append('circle').attr('cx', w / 2 + 50).attr('cy', ly).attr('r', 4)
580
+ .attr('fill', C.emerald + '40').attr('stroke', C.emerald);
581
+ labelText(g, w / 2 + 60, ly + 3, 'bit = 1 lattice (odd·Δ + Δ/2)', { color: C.emerald, size: 9, anchor: 'start' });
582
+
583
+ // Dither note
584
+ labelText(g, w / 2, ly + 24, 'Dither d (from secret key) shifts both lattices — prevents unauthorized detection', { color: C.muted, size: 9 });
585
+ },
586
+ },
587
+
588
+ // ──────────── 7. Payload Encoding ────────────
589
+ {
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;
605
+ const availW = w - pad * 2;
606
+ const gap = 30;
607
+ const boxW = Math.min(110, (availW - (steps.length - 1) * gap) / steps.length);
608
+ const totalW = steps.length * boxW + (steps.length - 1) * gap;
609
+ const startX = (w - totalW) / 2 + boxW / 2;
610
+
611
+ labelText(g, w / 2, 36, 'Payload encoding pipeline', { color: C.bright, size: 13, weight: '600' });
612
+
613
+ steps.forEach((step, i) => {
614
+ const cx = startX + i * (boxW + gap);
615
+
616
+ roundedRect(g, cx - boxW / 2, cy - 36, boxW, 72, {
617
+ fill: step.color + '12', stroke: step.color + '50', rx: 8,
618
+ });
619
+
620
+ labelText(g, cx, cy - 14, step.label, { color: C.bright, size: 12, weight: '600' });
621
+ labelText(g, cx, cy + 2, step.label2, { color: C.bright, size: 11, weight: '600' });
622
+ labelText(g, cx, cy + 18, step.desc, { color: C.dim, size: 8 });
623
+
624
+ // Bit count badge
625
+ g.append('rect')
626
+ .attr('x', cx - 18).attr('y', cy + 40)
627
+ .attr('width', 36).attr('height', 18)
628
+ .attr('fill', step.color + '20')
629
+ .attr('stroke', step.color + '40')
630
+ .attr('rx', 9);
631
+ labelText(g, cx, cy + 52, `${step.bits} bits`, { color: step.color, size: 8, weight: '600' });
632
+
633
+ if (i < steps.length - 1) {
634
+ arrow(g, cx + boxW / 2 + 4, cy, cx + boxW / 2 + gap - 4, cy, step.color + 'a0', 1.5);
635
+ }
636
+ });
637
+ },
638
+ },
639
+
640
+ // ──────────── 8. Reconstruction ────────────
641
+ {
642
+ title: 'Reconstruction',
643
+ description: 'Inverse transform back to pixel domain',
644
+ explanation: [
645
+ 'After embedding modifies DCT coefficients within the HL subband, we reverse the transforms to reconstruct a full video frame. Inverse DCT converts frequency-domain blocks back to spatial samples. Inverse DWT recombines all subbands (including the modified HL) into the full-resolution Y plane.',
646
+ 'The resulting watermarked Y plane is nearly identical to the original — typical PSNR is above 38 dB (the "moderate" preset), meaning the per-pixel difference is invisible to the human eye.',
647
+ ],
648
+ draw(g, w, h) {
649
+ const cy = h * 0.32;
650
+ // Correct direction: embedded coefficients → iDCT → write HL → iDWT → watermarked Y
651
+ const steps = [
652
+ { label: 'Modified', label2: 'DCT coeffs', color: C.amber },
653
+ { label: 'iDCT →', label2: 'write HL', color: C.emerald },
654
+ { label: 'inverse', label2: 'DWT', color: C.blue },
655
+ { label: 'Watermarked', label2: 'Y plane', color: C.violet },
656
+ ];
657
+ const boxW = 110;
658
+ const gap = 34;
659
+ const totalW = steps.length * boxW + (steps.length - 1) * gap;
660
+ const startX = (w - totalW) / 2 + boxW / 2;
661
+
662
+ labelText(g, w / 2, 30, 'Reconstruction: frequency → spatial', { color: C.bright, size: 13, weight: '600' });
663
+
664
+ steps.forEach((step, i) => {
665
+ const cx = startX + i * (boxW + gap);
666
+
667
+ roundedRect(g, cx - boxW / 2, cy - 28, boxW, 56, {
668
+ fill: step.color + '12', stroke: step.color + '50', rx: 8,
669
+ });
670
+
671
+ labelText(g, cx, cy - 6, step.label, { color: C.bright, size: 11, weight: '600' });
672
+ labelText(g, cx, cy + 10, step.label2, { color: C.bright, size: 11, weight: '600' });
673
+
674
+ // Step number
675
+ g.append('circle')
676
+ .attr('cx', cx - boxW / 2 + 10).attr('cy', cy - 28 + 10)
677
+ .attr('r', 8).attr('fill', step.color + '30').attr('stroke', step.color + '60');
678
+ labelText(g, cx - boxW / 2 + 10, cy - 28 + 13, `${i + 1}`, { color: step.color, size: 8, weight: '700' });
679
+
680
+ if (i < steps.length - 1) {
681
+ arrow(g, cx + boxW / 2 + 4, cy, cx + boxW / 2 + gap - 4, cy, C.dim + 'a0', 1.5);
682
+ }
683
+ });
684
+
685
+ // Before/after comparison — centered below with generous spacing
686
+ const compY = cy + 66;
687
+ const compSize = 80;
688
+ const compGap = 70;
689
+ const compOx = w / 2 - compSize - compGap / 2;
690
+ const cells = 8;
691
+ const cs = compSize / cells;
692
+
693
+ const rng1 = d3.randomLcg(42);
694
+ for (let r = 0; r < cells; r++) {
695
+ for (let c = 0; c < cells; c++) {
696
+ const val = Math.floor(rng1() * 200 + 30);
697
+ g.append('rect')
698
+ .attr('x', compOx + c * cs).attr('y', compY + r * cs)
699
+ .attr('width', cs - 0.5).attr('height', cs - 0.5)
700
+ .attr('fill', d3.interpolateGreys(1 - val / 255)).attr('rx', 1);
701
+ }
702
+ }
703
+ labelText(g, compOx + compSize / 2, compY - 8, 'original', { color: C.dim, size: 9 });
704
+
705
+ const compOx2 = w / 2 + compGap / 2;
706
+ const rng2 = d3.randomLcg(42);
707
+ for (let r = 0; r < cells; r++) {
708
+ for (let c = 0; c < cells; c++) {
709
+ const val = Math.floor(rng2() * 200 + 30) + (Math.random() > 0.6 ? 1 : 0);
710
+ g.append('rect')
711
+ .attr('x', compOx2 + c * cs).attr('y', compY + r * cs)
712
+ .attr('width', cs - 0.5).attr('height', cs - 0.5)
713
+ .attr('fill', d3.interpolateGreys(1 - Math.min(255, val) / 255)).attr('rx', 1);
714
+ }
715
+ }
716
+ labelText(g, compOx2 + compSize / 2, compY - 8, 'watermarked', { color: C.violet, size: 9 });
717
+
718
+ labelText(g, w / 2, compY + compSize / 2 + 4, '≈', { color: C.emerald, size: 22, weight: '700' });
719
+ labelText(g, w / 2, compY + compSize + 16, 'PSNR > 38 dB — imperceptible', { color: C.dim, size: 9 });
720
+ },
721
+ },
722
+
723
+ // ──────────── 9. Detection ────────────
724
+ {
725
+ title: 'Detection',
726
+ description: 'Multi-frame soft detection and decoding',
727
+ explanation: [
728
+ 'Detection reverses the embedding path but never needs the original frame. Each frame is independently DWT-transformed, tiles are located in the HL subband, and DCT + DM-QIM soft extraction produces a signed confidence value per bit per tile.',
729
+ 'The real power comes from sheer repetition. Each of the 63 coded bits is embedded into ~12 mid-frequency coefficients per block, across ~196 blocks per tile, across ~24 tiles per frame, across multiple frames. That\'s thousands of independent readings per bit. By the law of large numbers, averaging this many noisy soft-bit estimates drives the error rate exponentially toward zero — even when each individual reading is heavily corrupted by compression or noise.',
730
+ 'After averaging, the combined soft bits are de-interleaved, BCH-decoded (correcting any residual errors), and CRC-verified. The combination of massive statistical redundancy from tiling + frames, algebraic error correction from BCH, and integrity checking from CRC makes the system robust even under severe degradation.',
731
+ ],
732
+ draw(g, w, h) {
733
+ const pad = 20; // horizontal padding from edges
734
+ labelText(g, w / 2, 28, 'Multi-frame detection pipeline', { color: C.bright, size: 13, weight: '600' });
735
+
736
+ // Frame icons — vertically use top third
737
+ const frameY = 56;
738
+ const frameCount = 5;
739
+ const frameW = 44;
740
+ const frameH = 32;
741
+ const frameGap = 16;
742
+ const framesW = frameCount * frameW + (frameCount - 1) * frameGap;
743
+ const framesOx = (w - framesW) / 2;
744
+
745
+ for (let i = 0; i < frameCount; i++) {
746
+ const fx = framesOx + i * (frameW + frameGap);
747
+ roundedRect(g, fx, frameY, frameW, frameH, {
748
+ fill: C.blue + '15', stroke: C.blue + '50', rx: 4,
749
+ });
750
+ for (let r = 0; r < 3; r++) {
751
+ for (let c = 0; c < 4; c++) {
752
+ g.append('rect')
753
+ .attr('x', fx + 4 + c * 9).attr('y', frameY + 4 + r * 8)
754
+ .attr('width', 7).attr('height', 6)
755
+ .attr('fill', C.blue + (10 + Math.floor(Math.random() * 20)).toString(16))
756
+ .attr('rx', 1);
757
+ }
758
+ }
759
+ labelText(g, fx + frameW / 2, frameY + frameH + 13, `f${i + 1}`, { color: C.dim, size: 8 });
760
+ }
761
+
762
+ labelText(g, w / 2, frameY + frameH + 28, 'each: DWT → HL → DCT → DM-QIM soft extract', { color: C.muted, size: 8 });
763
+
764
+ // Funnel arrows — use proportional spacing
765
+ const combineX = w / 2;
766
+ const combineY = frameY + frameH + 56;
767
+
768
+ for (let i = 0; i < frameCount; i++) {
769
+ const fx = framesOx + i * (frameW + frameGap) + frameW / 2;
770
+ arrow(g, fx, frameY + frameH + 32, combineX + (i - 2) * 6, combineY - 10, C.blue + '50');
771
+ }
772
+
773
+ // Soft-combine box
774
+ roundedRect(g, combineX - 75, combineY - 8, 150, 30, {
775
+ fill: C.blue + '15', stroke: C.blue + '50', rx: 6,
776
+ });
777
+ labelText(g, combineX, combineY + 11, 'average soft bits', { color: C.blue, size: 10, weight: '600' });
778
+
779
+ // Pipeline — use available width with padding
780
+ const pipeY = combineY + 52;
781
+ const pipeSteps = [
782
+ { label: 'De-interleave', color: C.amber },
783
+ { label: 'BCH decode', color: C.emerald },
784
+ { label: 'CRC verify', color: C.violet },
785
+ ];
786
+ const availW = w - pad * 2;
787
+ const pStepW = Math.min(120, (availW - 60) / 3);
788
+ const pGap = (availW - pipeSteps.length * pStepW) / (pipeSteps.length - 1);
789
+ const pStartX = pad + pStepW / 2;
790
+
791
+ arrow(g, combineX, combineY + 22, pStartX, pipeY - 12, C.blue + '60');
792
+
793
+ pipeSteps.forEach((step, i) => {
794
+ const px = pStartX + i * (pStepW + pGap);
795
+ roundedRect(g, px - pStepW / 2, pipeY - 14, pStepW, 28, {
796
+ fill: step.color + '12', stroke: step.color + '50', rx: 6,
797
+ });
798
+ labelText(g, px, pipeY + 4, step.label, { color: step.color, size: 10, weight: '600' });
799
+
800
+ if (i < pipeSteps.length - 1) {
801
+ arrow(g, px + pStepW / 2 + 2, pipeY, px + pStepW / 2 + pGap - 2, pipeY, step.color + '80');
802
+ }
803
+ });
804
+
805
+ // Result box — generous spacing below
806
+ const outY = pipeY + 38;
807
+ const lastPipeX = pStartX + (pipeSteps.length - 1) * (pStepW + pGap);
808
+ arrow(g, lastPipeX, pipeY + 14, w / 2, outY, C.violet + '80');
809
+
810
+ const resultW = 220;
811
+ roundedRect(g, w / 2 - resultW / 2, outY + 4, resultW, 58, {
812
+ fill: C.emerald + '08', stroke: C.emerald + '40', rx: 8,
813
+ });
814
+ labelText(g, w / 2, outY + 22, 'payload: 0xDEADBEEF', { color: C.emerald, size: 11, weight: '600' });
815
+ labelText(g, w / 2, outY + 38, 'confidence: 0.97', { color: C.dim, size: 9 });
816
+ labelText(g, w / 2, outY + 52, '12 / 16 tiles decoded · CRC ✓', { color: C.dim, size: 9 });
817
+ },
818
+ },
819
+ ];
820
+
821
+ // ── Main component ────────────────────────────────────────────
822
+
823
+ export default function HowItWorks() {
824
+ const svgRef = useRef<SVGSVGElement>(null);
825
+ const [stage, setStage] = useState(0);
826
+ const [svgDims, setSvgDims] = useState({ w: 800, h: 470 });
827
+
828
+ const total = stages.length;
829
+
830
+ const prev = useCallback(() => setStage(s => Math.max(0, s - 1)), []);
831
+ const next = useCallback(() => setStage(s => Math.min(total - 1, s + 1)), [total]);
832
+
833
+ // Keyboard navigation
834
+ useEffect(() => {
835
+ const handler = (e: KeyboardEvent) => {
836
+ if (e.key === 'ArrowLeft') prev();
837
+ if (e.key === 'ArrowRight') next();
838
+ };
839
+ window.addEventListener('keydown', handler);
840
+ return () => window.removeEventListener('keydown', handler);
841
+ }, [prev, next]);
842
+
843
+ // Responsive sizing
844
+ useEffect(() => {
845
+ const el = svgRef.current?.parentElement;
846
+ if (!el) return;
847
+ const observer = new ResizeObserver(entries => {
848
+ for (const entry of entries) {
849
+ const w = Math.min(entry.contentRect.width, 860);
850
+ setSvgDims({ w, h: Math.max(400, Math.min(500, w * 0.58)) });
851
+ }
852
+ });
853
+ observer.observe(el);
854
+ return () => observer.disconnect();
855
+ }, []);
856
+
857
+ // D3 render
858
+ useEffect(() => {
859
+ const svg = d3.select(svgRef.current);
860
+ if (!svg.node()) return;
861
+
862
+ svg.selectAll('*').remove();
863
+
864
+ const { w, h } = svgDims;
865
+ svg.attr('width', w).attr('height', h)
866
+ .attr('viewBox', `0 0 ${w} ${h}`);
867
+
868
+ const root = svg.append('g');
869
+
870
+ root.attr('opacity', 0)
871
+ .transition()
872
+ .duration(350)
873
+ .ease(d3.easeCubicOut)
874
+ .attr('opacity', 1);
875
+
876
+ stages[stage].draw(root as unknown as d3.Selection<SVGGElement, unknown, null, undefined>, w, h);
877
+ }, [stage, svgDims]);
878
+
879
+ const current = stages[stage];
880
+
881
+ return (
882
+ <div className="space-y-6">
883
+ {/* Stage header */}
884
+ <div className="text-center">
885
+ <span className="inline-block rounded-full bg-blue-500/10 px-3 py-0.5 text-[11px] font-semibold tracking-wide text-blue-400 ring-1 ring-blue-500/20">
886
+ {stage + 1} / {total}
887
+ </span>
888
+ <h3 className="mt-3 text-xl font-bold tracking-tight text-zinc-100">
889
+ {current.title}
890
+ </h3>
891
+ <p className="mt-1 text-sm text-zinc-500">{current.description}</p>
892
+ </div>
893
+
894
+ {/* SVG canvas */}
895
+ <div className="flex justify-center overflow-hidden rounded-xl bg-zinc-900/60 ring-1 ring-zinc-800/50">
896
+ <svg ref={svgRef} className="block" />
897
+ </div>
898
+
899
+ {/* Explanation text */}
900
+ <div className="space-y-2 rounded-lg bg-zinc-900/40 px-4 py-3 ring-1 ring-zinc-800/40">
901
+ {current.explanation.map((para, i) => (
902
+ <p key={i} className="text-xs leading-relaxed text-zinc-400">
903
+ {para}
904
+ </p>
905
+ ))}
906
+ </div>
907
+
908
+ {/* Navigation */}
909
+ <div className="flex items-center justify-between">
910
+ <button
911
+ onClick={prev}
912
+ disabled={stage === 0}
913
+ className="flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-zinc-400 transition-all hover:bg-zinc-800 hover:text-zinc-200 disabled:pointer-events-none disabled:opacity-30"
914
+ >
915
+ <span className="text-xs">←</span> Prev
916
+ </button>
917
+
918
+ {/* Dots */}
919
+ <div className="flex gap-1.5">
920
+ {stages.map((_, i) => (
921
+ <button
922
+ key={i}
923
+ onClick={() => setStage(i)}
924
+ className={`h-2 rounded-full transition-all ${
925
+ i === stage
926
+ ? 'w-6 bg-blue-500'
927
+ : 'w-2 bg-zinc-700 hover:bg-zinc-500'
928
+ }`}
929
+ aria-label={`Stage ${i + 1}`}
930
+ />
931
+ ))}
932
+ </div>
933
+
934
+ <button
935
+ onClick={next}
936
+ disabled={stage === total - 1}
937
+ className="flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-zinc-400 transition-all hover:bg-zinc-800 hover:text-zinc-200 disabled:pointer-events-none disabled:opacity-30"
938
+ >
939
+ Next <span className="text-xs">→</span>
940
+ </button>
941
+ </div>
942
+ </div>
943
+ );
944
+ }
web/src/lib/video-io.ts CHANGED
@@ -122,6 +122,28 @@ export async function streamExtractAndEmbed(
122
  let totalPsnr = 0;
123
 
124
  const ffmpeg = await getFFmpeg();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  const segments: string[] = [];
126
 
127
  // Chunk buffer — reused across chunks
@@ -203,17 +225,15 @@ export async function streamExtractAndEmbed(
203
  // Free chunk buffer
204
  chunkBuffer = null!;
205
 
206
- // Concatenate all segments
207
- let blob: Blob;
208
  if (segments.length === 1) {
209
- // Single segment — just use it directly
210
  const data = await ffmpeg.readFile(segments[0]);
211
  await ffmpeg.deleteFile(segments[0]);
212
- if (typeof data === 'string') throw new Error('Unexpected string output from ffmpeg');
213
- blob = new Blob([(data as unknown as { buffer: ArrayBuffer }).buffer], { type: 'video/mp4' });
214
  } else {
215
  onProgress?.('Joining segments', 0, 0);
216
- // Write concat file list
217
  const concatList = segments.map((s) => `file '${s}'`).join('\n');
218
  await ffmpeg.writeFile('concat.txt', concatList);
219
 
@@ -224,18 +244,43 @@ export async function streamExtractAndEmbed(
224
  '-c', 'copy',
225
  '-movflags', '+faststart',
226
  '-y',
227
- 'final.mp4',
228
  ]);
229
 
230
  await ffmpeg.deleteFile('concat.txt');
231
  for (const seg of segments) {
232
  await ffmpeg.deleteFile(seg);
233
  }
 
234
 
235
- const data = await ffmpeg.readFile('final.mp4');
236
- await ffmpeg.deleteFile('final.mp4');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  if (typeof data === 'string') throw new Error('Unexpected string output from ffmpeg');
238
  blob = new Blob([(data as unknown as { buffer: ArrayBuffer }).buffer], { type: 'video/mp4' });
 
 
239
  }
240
 
241
  return {
 
122
  let totalPsnr = 0;
123
 
124
  const ffmpeg = await getFFmpeg();
125
+
126
+ // --- Extract audio from original video (if any) ---
127
+ let hasAudio = false;
128
+ {
129
+ const resp = await fetch(videoUrl);
130
+ const origBytes = new Uint8Array(await resp.arrayBuffer());
131
+ await ffmpeg.writeFile('original_input.mp4', origBytes);
132
+ try {
133
+ await ffmpeg.exec([
134
+ '-i', 'original_input.mp4',
135
+ '-vn', '-acodec', 'copy',
136
+ '-y', 'audio_track.m4a',
137
+ ]);
138
+ // Check if audio file was actually produced (non-empty)
139
+ const audioData = await ffmpeg.readFile('audio_track.m4a');
140
+ hasAudio = typeof audioData !== 'string' && (audioData as Uint8Array).length > 0;
141
+ } catch {
142
+ hasAudio = false;
143
+ }
144
+ await ffmpeg.deleteFile('original_input.mp4');
145
+ }
146
+
147
  const segments: string[] = [];
148
 
149
  // Chunk buffer — reused across chunks
 
225
  // Free chunk buffer
226
  chunkBuffer = null!;
227
 
228
+ // Concatenate all segments into video-only file
229
+ const videoOnlyFile = 'video_only.mp4';
230
  if (segments.length === 1) {
231
+ // Single segment — rename it
232
  const data = await ffmpeg.readFile(segments[0]);
233
  await ffmpeg.deleteFile(segments[0]);
234
+ await ffmpeg.writeFile(videoOnlyFile, data);
 
235
  } else {
236
  onProgress?.('Joining segments', 0, 0);
 
237
  const concatList = segments.map((s) => `file '${s}'`).join('\n');
238
  await ffmpeg.writeFile('concat.txt', concatList);
239
 
 
244
  '-c', 'copy',
245
  '-movflags', '+faststart',
246
  '-y',
247
+ videoOnlyFile,
248
  ]);
249
 
250
  await ffmpeg.deleteFile('concat.txt');
251
  for (const seg of segments) {
252
  await ffmpeg.deleteFile(seg);
253
  }
254
+ }
255
 
256
+ // Mux audio back in if it existed in the original
257
+ let blob: Blob;
258
+ if (hasAudio) {
259
+ onProgress?.('Muxing audio', 0, 0);
260
+ await ffmpeg.exec([
261
+ '-i', videoOnlyFile,
262
+ '-i', 'audio_track.m4a',
263
+ '-c:v', 'copy',
264
+ '-c:a', 'copy',
265
+ '-shortest',
266
+ '-movflags', '+faststart',
267
+ '-y',
268
+ 'final_with_audio.mp4',
269
+ ]);
270
+ await ffmpeg.deleteFile(videoOnlyFile);
271
+ await ffmpeg.deleteFile('audio_track.m4a');
272
+
273
+ const data = await ffmpeg.readFile('final_with_audio.mp4');
274
+ await ffmpeg.deleteFile('final_with_audio.mp4');
275
+ if (typeof data === 'string') throw new Error('Unexpected string output from ffmpeg');
276
+ blob = new Blob([(data as unknown as { buffer: ArrayBuffer }).buffer], { type: 'video/mp4' });
277
+ } else {
278
+ const data = await ffmpeg.readFile(videoOnlyFile);
279
+ await ffmpeg.deleteFile(videoOnlyFile);
280
  if (typeof data === 'string') throw new Error('Unexpected string output from ffmpeg');
281
  blob = new Blob([(data as unknown as { buffer: ArrayBuffer }).buffer], { type: 'video/mp4' });
282
+ // Clean up audio file if extraction was attempted
283
+ try { await ffmpeg.deleteFile('audio_track.m4a'); } catch { /* no-op */ }
284
  }
285
 
286
  return {