Spaces:
Running
Running
Upload 29 files
Browse files- core/detector.ts +9 -4
- core/masking.ts +11 -5
- core/presets.ts +5 -5
- core/tiling.ts +8 -3
- web/.DS_Store +0 -0
- web/src/.DS_Store +0 -0
- web/src/App.tsx +28 -16
- web/src/components/ApiDocs.tsx +6 -38
- web/src/components/EmbedPanel.tsx +2 -2
- web/src/components/HowItWorks.tsx +944 -0
- web/src/lib/video-io.ts +54 -9
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
|
| 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 |
-
|
| 115 |
|
| 116 |
bitIdx++;
|
| 117 |
}
|
| 118 |
}
|
| 119 |
|
| 120 |
for (let i = 0; i < codedLength; i++) {
|
| 121 |
-
if (
|
| 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.
|
| 27 |
-
* - Smooth blocks → factor
|
| 28 |
-
* - Textured blocks → factor
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 22 |
temporalFrames: 1,
|
| 23 |
},
|
| 24 |
|
| 25 |
moderate: {
|
| 26 |
-
alpha: 0.
|
| 27 |
-
delta:
|
| 28 |
-
tilePeriod:
|
| 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: '⚡
|
| 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 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 13 |
<div className="flex items-center gap-3">
|
| 14 |
-
<div className="flex h-
|
| 15 |
-
<svg className="h-
|
| 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 |
-
<
|
| 20 |
-
<
|
| 21 |
-
|
| 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=
|
| 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 |
-
:
|
|
|
|
|
|
|
| 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=
|
| 80 |
<p className="text-center text-[11px] text-zinc-700">
|
| 81 |
-
|
| 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 |
-
|
|
|
|
| 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
|
| 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: '
|
| 225 |
-
{ name: 'Moderate', delta:
|
| 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>('
|
| 31 |
-
const [alpha, setAlpha] = useState(0.
|
| 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 |
-
|
| 208 |
if (segments.length === 1) {
|
| 209 |
-
// Single segment —
|
| 210 |
const data = await ffmpeg.readFile(segments[0]);
|
| 211 |
await ffmpeg.deleteFile(segments[0]);
|
| 212 |
-
|
| 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 |
-
|
| 228 |
]);
|
| 229 |
|
| 230 |
await ffmpeg.deleteFile('concat.txt');
|
| 231 |
for (const seg of segments) {
|
| 232 |
await ffmpeg.deleteFile(seg);
|
| 233 |
}
|
|
|
|
| 234 |
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|