harelcain commited on
Commit
f2f99a3
·
verified ·
1 Parent(s): 3e6437a

Upload 37 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:22-slim AS builder
2
+
3
+ # Install FFmpeg for server-side video processing
4
+ RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
5
+
6
+ WORKDIR /app
7
+
8
+ # Copy package files and install dependencies
9
+ COPY package.json package-lock.json* ./
10
+ RUN npm ci --ignore-scripts
11
+
12
+ # Copy source code
13
+ COPY . .
14
+
15
+ # Build web UI and check types
16
+ RUN npx vite build --config vite.config.ts
17
+
18
+ # Production stage
19
+ FROM node:22-slim
20
+
21
+ RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
22
+
23
+ WORKDIR /app
24
+
25
+ COPY package.json package-lock.json* ./
26
+ RUN npm ci --ignore-scripts --omit=dev 2>/dev/null || npm ci --ignore-scripts
27
+
28
+ # Copy built web assets and source (for tsx runtime)
29
+ COPY --from=builder /app/dist/web ./dist/web
30
+ COPY core/ ./core/
31
+ COPY server/ ./server/
32
+
33
+ # HuggingFace Spaces expects port 7860
34
+ ENV PORT=7860
35
+ ENV STATIC_DIR=/app/dist/web
36
+ EXPOSE 7860
37
+
38
+ # Run the API server using tsx for TypeScript execution
39
+ RUN npm install -g tsx
40
+ CMD ["tsx", "server/api.ts"]
README.md CHANGED
@@ -1,10 +1,171 @@
1
  ---
2
- title: Ltmarx
3
- emoji: 📚
4
- colorFrom: green
5
- colorTo: blue
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: LTMarX
3
+ emoji: 🎬
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # LTMarX Video Watermarking
12
+
13
+ Imperceptible 32-bit watermarking for video. Embeds a payload into the luminance channel using DWT/DCT transform-domain quantization (DM-QIM) with BCH error correction. Survives re-encoding, rescaling, brightness/contrast/saturation changes.
14
+
15
+ All processing runs in the browser — no server round-trips needed.
16
+
17
+ ## Quick Start
18
+
19
+ ```bash
20
+ npm install
21
+ npm run dev # Web UI at localhost:5173
22
+ npm test # Run test suite
23
+ ```
24
+
25
+ ## CLI
26
+
27
+ ```bash
28
+ npx tsx server/cli.ts embed -i input.mp4 -o output.mp4 --key SECRET --preset moderate --payload DEADBEEF
29
+ npx tsx server/cli.ts detect -i output.mp4 --key SECRET
30
+ npx tsx server/cli.ts presets
31
+ ```
32
+
33
+ ## Docker
34
+
35
+ ```bash
36
+ docker build -t ltmarx .
37
+ docker run -p 7860:7860 ltmarx
38
+ ```
39
+
40
+ ## Architecture
41
+
42
+ ```
43
+ core/ Pure TypeScript watermark engine (isomorphic, zero platform deps)
44
+ ├── dwt.ts Haar DWT (forward/inverse, multi-level)
45
+ ├── dct.ts 8×8 DCT with zigzag scan
46
+ ├── dmqim.ts Dither-Modulated QIM (embed/extract with soft decisions)
47
+ ├── bch.ts BCH codec (GF(2^m), Berlekamp-Massey decoding)
48
+ ├── crc.ts CRC-4 / CRC-8 / CRC-16
49
+ ├── tiling.ts Periodic tile layout for redundant embedding
50
+ ├── masking.ts Perceptual masking (variance-adaptive delta)
51
+ ├── keygen.ts Seeded PRNG for dithers and permutations
52
+ ├── embedder.ts Y-plane → watermarked Y-plane
53
+ ├── detector.ts Y-plane → payload + confidence
54
+ ├── presets.ts Named configurations (light → fortress)
55
+ └── types.ts Shared types
56
+
57
+ web/ Frontend (Vite + React + Tailwind)
58
+ ├── src/
59
+ │ ├── App.tsx
60
+ │ ├── components/
61
+ │ │ ├── EmbedPanel.tsx Upload, configure, embed
62
+ │ │ ├── DetectPanel.tsx Upload, detect, display results
63
+ │ │ ├── ApiDocs.tsx Inline API reference
64
+ │ │ ├── ComparisonView.tsx Side-by-side / difference viewer
65
+ │ │ ├── RobustnessTest.tsx Automated attack testing
66
+ │ │ ├── StrengthSlider.tsx Preset selector with snap points
67
+ │ │ └── ResultCard.tsx Detection result display
68
+ │ ├── lib/
69
+ │ │ └── video-io.ts Frame extraction, encoding, attack utilities
70
+ │ └── workers/
71
+ │ └── watermark.worker.ts
72
+ └── index.html
73
+
74
+ server/ Node.js CLI + HTTP API
75
+ ├── cli.ts CLI for embed/detect
76
+ ├── api.ts HTTP server (serves web UI + REST endpoints)
77
+ └── ffmpeg-io.ts FFmpeg subprocess for YUV420p I/O
78
+
79
+ tests/ Vitest test suite
80
+ ```
81
+
82
+ **Design principle:** `core/` has zero platform dependencies — it operates on raw `Uint8Array` Y-plane buffers. The same code runs in the browser (via Canvas + ffmpeg.wasm) and on the server (via Node.js + FFmpeg).
83
+
84
+ ## Watermarking Pipeline
85
+
86
+ ### Embedding
87
+
88
+ ```
89
+ Y plane → 2-level Haar DWT → HL subband → tile grid →
90
+ per tile: 8×8 DCT blocks → select mid-freq coefficients →
91
+ DM-QIM embed coded bits → inverse DCT → inverse DWT → modified Y plane
92
+ ```
93
+
94
+ ### Payload Encoding
95
+
96
+ ```
97
+ 32-bit payload → CRC append → BCH encode → keyed interleave → map to coefficients
98
+ ```
99
+
100
+ ### Detection
101
+
102
+ ```
103
+ Y plane → DWT → HL subband → tile grid →
104
+ per tile: DCT → DM-QIM soft extract →
105
+ soft-combine across tiles and frames → BCH decode → CRC verify → payload
106
+ ```
107
+
108
+ ## Presets
109
+
110
+ | Preset | Delta | Tile Period | BCH Code | Masking | Use Case |
111
+ |--------|-------|-------------|----------|---------|----------|
112
+ | **Light** | 50 | 256px | (63,36,5) | No | Near-invisible, mild compression |
113
+ | **Moderate** | 80 | 232px | (63,36,5) | Yes | Balanced with perceptual masking |
114
+ | **Strong** | 110 | 208px | (63,36,5) | Yes | More frequencies, handles rescaling |
115
+ | **Fortress** | 150 | 192px | (63,36,5) | Yes | Maximum robustness |
116
+
117
+ ## API
118
+
119
+ ### Embedding
120
+
121
+ ```typescript
122
+ import { embedWatermark } from './core/embedder';
123
+ import { getPreset } from './core/presets';
124
+
125
+ const config = getPreset('moderate');
126
+ const result = embedWatermark(yPlane, width, height, payload, key, config);
127
+ // result.yPlane: watermarked Y plane
128
+ // result.psnr: quality metric (dB)
129
+ ```
130
+
131
+ ### Detection
132
+
133
+ ```typescript
134
+ import { detectWatermarkMultiFrame } from './core/detector';
135
+ import { getPreset } from './core/presets';
136
+
137
+ const result = detectWatermarkMultiFrame(yPlanes, width, height, key, config);
138
+ // result.detected: boolean
139
+ // result.payload: Uint8Array | null
140
+ // result.confidence: 0–1
141
+ ```
142
+
143
+ ### Auto-Detection (tries all presets)
144
+
145
+ ```typescript
146
+ import { autoDetectMultiFrame } from './core/detector';
147
+
148
+ const result = autoDetectMultiFrame(yPlanes, width, height, key);
149
+ // result.presetUsed: which preset matched
150
+ ```
151
+
152
+ ## HTTP API
153
+
154
+ ```
155
+ POST /api/embed { videoBase64, key, preset, payload }
156
+ POST /api/detect { videoBase64, key, preset?, frames? }
157
+ GET /api/health → { status: "ok" }
158
+ ```
159
+
160
+ ## Testing
161
+
162
+ ```bash
163
+ npm test # Run all tests
164
+ npm run test:watch # Watch mode
165
+ ```
166
+
167
+ Tests cover: DWT round-trip, DCT round-trip, DM-QIM embed/extract, BCH encode/decode with error correction, CRC append/verify, full embed→detect pipeline across presets, false positive rejection, wrong key rejection.
168
+
169
+ ## Browser Encoding
170
+
171
+ The web UI encodes watermarked video using ffmpeg.wasm (x264 in WebAssembly). To avoid memory pressure, frames are encoded in chunks of 100 and concatenated at the end. Peak memory stays at ~chunk size rather than scaling with video length.
core/bch.ts ADDED
@@ -0,0 +1,395 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * BCH (Bose-Chaudhuri-Hocquenghem) Error-Correcting Code
3
+ *
4
+ * Supports configurable BCH(n, k, t) codes over GF(2^m).
5
+ * Uses Berlekamp-Massey decoding with Chien search.
6
+ */
7
+
8
+ import type { BchParams } from './types.js';
9
+
10
+ // Primitive polynomials for GF(2^m)
11
+ const PRIMITIVE_POLYS: Record<number, number> = {
12
+ 6: 0x43, // x^6 + x + 1
13
+ 7: 0x89, // x^7 + x^3 + 1
14
+ 8: 0x11D, // x^8 + x^4 + x^3 + x^2 + 1
15
+ };
16
+
17
+ /**
18
+ * Galois Field GF(2^m) arithmetic
19
+ */
20
+ export class GaloisField {
21
+ readonly m: number;
22
+ readonly size: number; // 2^m
23
+ readonly expTable: Int32Array; // alpha^i → element
24
+ readonly logTable: Int32Array; // element → i (log_alpha)
25
+
26
+ constructor(m: number) {
27
+ this.m = m;
28
+ this.size = 1 << m;
29
+ const poly = PRIMITIVE_POLYS[m];
30
+ if (!poly) throw new Error(`No primitive polynomial for GF(2^${m})`);
31
+
32
+ this.expTable = new Int32Array(this.size * 2);
33
+ this.logTable = new Int32Array(this.size);
34
+ this.logTable[0] = -1; // log(0) undefined
35
+
36
+ let val = 1;
37
+ for (let i = 0; i < this.size - 1; i++) {
38
+ this.expTable[i] = val;
39
+ this.logTable[val] = i;
40
+ val <<= 1;
41
+ if (val >= this.size) {
42
+ val ^= poly;
43
+ val &= this.size - 1;
44
+ }
45
+ }
46
+ // Extend exp table for easier modular arithmetic
47
+ for (let i = this.size - 1; i < this.size * 2; i++) {
48
+ this.expTable[i] = this.expTable[i - (this.size - 1)];
49
+ }
50
+ }
51
+
52
+ mul(a: number, b: number): number {
53
+ if (a === 0 || b === 0) return 0;
54
+ return this.expTable[this.logTable[a] + this.logTable[b]];
55
+ }
56
+
57
+ div(a: number, b: number): number {
58
+ if (b === 0) throw new Error('Division by zero in GF');
59
+ if (a === 0) return 0;
60
+ let exp = this.logTable[a] - this.logTable[b];
61
+ if (exp < 0) exp += this.size - 1;
62
+ return this.expTable[exp];
63
+ }
64
+
65
+ pow(a: number, e: number): number {
66
+ if (a === 0) return e === 0 ? 1 : 0;
67
+ let log = (this.logTable[a] * e) % (this.size - 1);
68
+ if (log < 0) log += this.size - 1;
69
+ return this.expTable[log];
70
+ }
71
+
72
+ inv(a: number): number {
73
+ if (a === 0) throw new Error('Inverse of zero');
74
+ return this.expTable[this.size - 1 - this.logTable[a]];
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Compute the generator polynomial for a binary BCH code.
80
+ * The generator is the LCM of minimal polynomials of alpha^1 through alpha^(2t).
81
+ * All minimal polynomials over GF(2) have binary (0/1) coefficients.
82
+ */
83
+ function computeGeneratorPoly(gf: GaloisField, t: number): Uint8Array {
84
+ // Start with g(x) = 1 as a binary polynomial
85
+ let gen = new Uint8Array([1]);
86
+ const order = gf.size - 1;
87
+ const processed = new Set<number>();
88
+
89
+ for (let i = 1; i <= 2 * t; i++) {
90
+ // Normalize i modulo the field order
91
+ const norm = i % order;
92
+ if (processed.has(norm)) continue;
93
+
94
+ // Find conjugate class of alpha^i: {i, 2i, 4i, ...} mod order
95
+ const conjugates: number[] = [];
96
+ let r = norm;
97
+ for (let j = 0; j < gf.m; j++) {
98
+ const rNorm = r % order;
99
+ if (conjugates.includes(rNorm)) break;
100
+ conjugates.push(rNorm);
101
+ processed.add(rNorm);
102
+ r = (r * 2) % order;
103
+ }
104
+
105
+ // Compute minimal polynomial: product of (x + alpha^c) for c in conjugate class
106
+ // Start with [1] (= 1), then multiply by each (x + alpha^c)
107
+ // Coefficients are in GF(2^m), but we reduce to binary at the end
108
+ let minPoly = [1]; // GF(2^m) coefficients
109
+ for (const c of conjugates) {
110
+ const root = gf.expTable[c];
111
+ const newPoly = new Array(minPoly.length + 1).fill(0);
112
+ for (let k = 0; k < minPoly.length; k++) {
113
+ newPoly[k + 1] ^= minPoly[k]; // x * term
114
+ newPoly[k] ^= gf.mul(minPoly[k], root); // root * term
115
+ }
116
+ minPoly = newPoly;
117
+ }
118
+
119
+ // Convert to binary (all coefficients should be 0 or 1 for minimal polys over GF(2))
120
+ const binaryMinPoly = new Uint8Array(minPoly.length);
121
+ for (let j = 0; j < minPoly.length; j++) {
122
+ binaryMinPoly[j] = minPoly[j] === 0 ? 0 : 1;
123
+ }
124
+
125
+ // Multiply gen by binaryMinPoly (binary polynomial multiplication)
126
+ const newGen = new Uint8Array(gen.length + binaryMinPoly.length - 1);
127
+ for (let a = 0; a < gen.length; a++) {
128
+ if (!gen[a]) continue;
129
+ for (let b = 0; b < binaryMinPoly.length; b++) {
130
+ if (binaryMinPoly[b]) {
131
+ newGen[a + b] ^= 1;
132
+ }
133
+ }
134
+ }
135
+ gen = newGen;
136
+ }
137
+
138
+ return gen;
139
+ }
140
+
141
+ /**
142
+ * BCH Encoder/Decoder
143
+ */
144
+ export class BchCodec {
145
+ readonly params: BchParams;
146
+ readonly gf: GaloisField;
147
+ private readonly genPoly: Uint8Array;
148
+ private readonly genPolyDeg: number;
149
+
150
+ constructor(params: BchParams) {
151
+ this.params = params;
152
+ this.gf = new GaloisField(params.m);
153
+ this.genPoly = computeGeneratorPoly(this.gf, params.t);
154
+ this.genPolyDeg = this.genPoly.length - 1;
155
+ }
156
+
157
+ /**
158
+ * Encode message bits (length k) into codeword bits (length n)
159
+ * Systematic encoding: message bits appear at the start
160
+ */
161
+ encode(message: Uint8Array): Uint8Array {
162
+ const { n, k } = this.params;
163
+ if (message.length !== k) {
164
+ throw new Error(`Message length ${message.length} !== k=${k}`);
165
+ }
166
+
167
+ const nk = n - k;
168
+ const codeword = new Uint8Array(n);
169
+ codeword.set(message);
170
+
171
+ // Compute remainder: m(x) * x^(n-k) mod g(x) using binary polynomial long division
172
+ const dividend = new Uint8Array(n);
173
+ // Place message coefficients at positions nk..n-1 (high degree)
174
+ // In our polynomial representation, index i = coefficient of x^i
175
+ // For systematic encoding: codeword(x) = m(x) * x^(n-k) + remainder
176
+ for (let i = 0; i < k; i++) {
177
+ dividend[nk + i] = message[i];
178
+ }
179
+
180
+ // Long division
181
+ const rem = new Uint8Array(dividend);
182
+ for (let i = n - 1; i >= nk; i--) {
183
+ if (rem[i]) {
184
+ for (let j = 0; j <= this.genPolyDeg; j++) {
185
+ rem[i - this.genPolyDeg + j] ^= this.genPoly[j];
186
+ }
187
+ }
188
+ }
189
+
190
+ // Codeword = message (high positions) + remainder (low positions)
191
+ for (let i = 0; i < nk; i++) {
192
+ codeword[k + i] = message[i]; // Will be overwritten below
193
+ }
194
+
195
+ // Actually: codeword[i] for i=0..nk-1 is remainder, for i=nk..n-1 is message
196
+ // Let's use standard ordering: codeword = [message | parity]
197
+ // where c(x) = m(x) * x^(n-k) + (m(x) * x^(n-k) mod g(x))
198
+ for (let i = 0; i < k; i++) {
199
+ codeword[i] = message[i];
200
+ }
201
+ for (let i = 0; i < nk; i++) {
202
+ codeword[k + i] = rem[i];
203
+ }
204
+
205
+ return codeword;
206
+ }
207
+
208
+ /**
209
+ * Decode a received codeword. Returns corrected message bits or null if uncorrectable.
210
+ */
211
+ decode(received: Uint8Array): Uint8Array | null {
212
+ const { n, k, t } = this.params;
213
+ if (received.length !== n) {
214
+ throw new Error(`Received length ${received.length} !== n=${n}`);
215
+ }
216
+
217
+ // Convert to polynomial form matching our encoding
218
+ // received[0..k-1] = message bits, received[k..n-1] = parity bits
219
+ // As polynomial: r(x) = sum r[i] * x^(n-k+i) for i=0..k-1, + sum r[k+i] * x^i for i=0..n-k-1
220
+ const nk = n - k;
221
+ const rPoly = new Uint8Array(n);
222
+ for (let i = 0; i < k; i++) {
223
+ rPoly[nk + i] = received[i];
224
+ }
225
+ for (let i = 0; i < nk; i++) {
226
+ rPoly[i] = received[k + i];
227
+ }
228
+
229
+ // Compute syndromes S_1 ... S_{2t}
230
+ // S_j = r(alpha^j) for j = 1..2t
231
+ const syndromes = new Array<number>(2 * t);
232
+ let hasErrors = false;
233
+
234
+ for (let j = 0; j < 2 * t; j++) {
235
+ let s = 0;
236
+ let alphaPow = 1; // alpha^((j+1)*0)
237
+ const alphaJ = this.gf.expTable[j + 1]; // alpha^(j+1)
238
+ for (let i = 0; i < n; i++) {
239
+ if (rPoly[i]) {
240
+ s ^= alphaPow;
241
+ }
242
+ alphaPow = this.gf.mul(alphaPow, alphaJ);
243
+ }
244
+ syndromes[j] = s;
245
+ if (s !== 0) hasErrors = true;
246
+ }
247
+
248
+ if (!hasErrors) {
249
+ return new Uint8Array(received.subarray(0, k));
250
+ }
251
+
252
+ // Berlekamp-Massey to find error locator polynomial
253
+ const sigma = this.berlekampMassey(syndromes, t);
254
+ if (!sigma) return null;
255
+
256
+ // Chien search to find error positions in polynomial representation
257
+ const errorPolyPositions = this.chienSearch(sigma, n);
258
+ if (!errorPolyPositions || errorPolyPositions.length !== sigma.length - 1) {
259
+ return null;
260
+ }
261
+
262
+ // Correct errors in polynomial representation
263
+ const correctedPoly = new Uint8Array(rPoly);
264
+ for (const pos of errorPolyPositions) {
265
+ if (pos >= n) return null;
266
+ correctedPoly[pos] ^= 1;
267
+ }
268
+
269
+ // Verify: recompute syndromes should all be zero
270
+ for (let j = 0; j < 2 * t; j++) {
271
+ let s = 0;
272
+ let alphaPow = 1;
273
+ const alphaJ = this.gf.expTable[j + 1];
274
+ for (let i = 0; i < n; i++) {
275
+ if (correctedPoly[i]) s ^= alphaPow;
276
+ alphaPow = this.gf.mul(alphaPow, alphaJ);
277
+ }
278
+ if (s !== 0) return null;
279
+ }
280
+
281
+ // Extract message bits from corrected polynomial
282
+ const message = new Uint8Array(k);
283
+ for (let i = 0; i < k; i++) {
284
+ message[i] = correctedPoly[nk + i];
285
+ }
286
+ return message;
287
+ }
288
+
289
+ /**
290
+ * Berlekamp-Massey algorithm
291
+ */
292
+ private berlekampMassey(syndromes: number[], t: number): number[] | null {
293
+ const gf = this.gf;
294
+ const twoT = 2 * t;
295
+
296
+ let C = [1];
297
+ let B = [1];
298
+ let L = 0;
299
+ let m = 1;
300
+ let b = 1;
301
+
302
+ for (let n = 0; n < twoT; n++) {
303
+ let d = syndromes[n];
304
+ for (let i = 1; i <= L; i++) {
305
+ if (i < C.length && (n - i) >= 0) {
306
+ d ^= gf.mul(C[i], syndromes[n - i]);
307
+ }
308
+ }
309
+
310
+ if (d === 0) {
311
+ m++;
312
+ } else if (2 * L <= n) {
313
+ const T = [...C];
314
+ const factor = gf.div(d, b);
315
+ while (C.length < B.length + m) C.push(0);
316
+ for (let i = 0; i < B.length; i++) {
317
+ C[i + m] ^= gf.mul(factor, B[i]);
318
+ }
319
+ L = n + 1 - L;
320
+ B = T;
321
+ b = d;
322
+ m = 1;
323
+ } else {
324
+ const factor = gf.div(d, b);
325
+ while (C.length < B.length + m) C.push(0);
326
+ for (let i = 0; i < B.length; i++) {
327
+ C[i + m] ^= gf.mul(factor, B[i]);
328
+ }
329
+ m++;
330
+ }
331
+ }
332
+
333
+ if (L > t) return null;
334
+ return C;
335
+ }
336
+
337
+ /**
338
+ * Chien search: find roots of the error locator polynomial.
339
+ *
340
+ * sigma(x) = prod(1 - alpha^(e_j) * x), so roots are at x = alpha^(-e_j).
341
+ * We test x = alpha^(-i) for i = 0..n-1. If sigma(alpha^(-i)) = 0,
342
+ * then error position is i (in polynomial index).
343
+ */
344
+ private chienSearch(sigma: number[], n: number): number[] | null {
345
+ const gf = this.gf;
346
+ const positions: number[] = [];
347
+ const degree = sigma.length - 1;
348
+ const order = gf.size - 1;
349
+
350
+ for (let i = 0; i < n; i++) {
351
+ // Evaluate sigma at alpha^(-i)
352
+ let val = sigma[0]; // = 1
353
+ for (let j = 1; j < sigma.length; j++) {
354
+ // alpha^(-i*j) = alpha^(order - i*j mod order)
355
+ let exp = (order - ((i * j) % order)) % order;
356
+ val ^= gf.mul(sigma[j], gf.expTable[exp]);
357
+ }
358
+ if (val === 0) {
359
+ positions.push(i);
360
+ }
361
+ }
362
+
363
+ if (positions.length !== degree) return null;
364
+ return positions;
365
+ }
366
+
367
+ /**
368
+ * Soft-decode: use soft input values to attempt decoding
369
+ */
370
+ decodeSoft(softBits: Float64Array): { message: Uint8Array | null; reliable: boolean } {
371
+ const hard = new Uint8Array(softBits.length);
372
+ for (let i = 0; i < softBits.length; i++) {
373
+ hard[i] = softBits[i] > 0 ? 1 : 0;
374
+ }
375
+
376
+ const decoded = this.decode(hard);
377
+ if (decoded) {
378
+ let reliabilitySum = 0;
379
+ for (let i = 0; i < softBits.length; i++) {
380
+ reliabilitySum += Math.abs(softBits[i]);
381
+ }
382
+ const avgReliability = reliabilitySum / softBits.length;
383
+ return { message: decoded, reliable: avgReliability > 0.15 };
384
+ }
385
+
386
+ return { message: null, reliable: false };
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Create BCH codec from standard parameters
392
+ */
393
+ export function createBchCodec(params: BchParams): BchCodec {
394
+ return new BchCodec(params);
395
+ }
core/crc.ts ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * CRC computation for payload integrity verification
3
+ * Supports CRC-4, CRC-8, and CRC-16
4
+ */
5
+
6
+ // CRC-4 polynomial: x^4 + x + 1 (0x13, used bits: 0x03)
7
+ const CRC4_POLY = 0x03;
8
+ // CRC-8 polynomial: x^8 + x^2 + x + 1 (CRC-8-CCITT)
9
+ const CRC8_POLY = 0x07;
10
+ // CRC-16 polynomial: x^16 + x^15 + x^2 + 1 (CRC-16-IBM)
11
+ const CRC16_POLY = 0x8005;
12
+
13
+ /**
14
+ * Compute CRC over a bit array
15
+ * Uses non-zero initial value to prevent all-zeros from having CRC=0
16
+ */
17
+ function computeCrc(bits: Uint8Array, poly: number, crcBits: number): number {
18
+ const mask = (1 << crcBits) - 1;
19
+ let crc = mask; // Non-zero init: all 1s (0xF for CRC-4, 0xFF for CRC-8, 0xFFFF for CRC-16)
20
+
21
+ for (let i = 0; i < bits.length; i++) {
22
+ crc ^= (bits[i] & 1) << (crcBits - 1);
23
+ if (crc & (1 << (crcBits - 1))) {
24
+ crc = ((crc << 1) ^ poly) & mask;
25
+ } else {
26
+ crc = (crc << 1) & mask;
27
+ }
28
+ }
29
+
30
+ return crc;
31
+ }
32
+
33
+ /**
34
+ * Compute CRC and append to bit array
35
+ */
36
+ export function crcAppend(bits: Uint8Array, crcBits: 4 | 8 | 16): Uint8Array {
37
+ const poly = crcBits === 4 ? CRC4_POLY : crcBits === 8 ? CRC8_POLY : CRC16_POLY;
38
+ const crc = computeCrc(bits, poly, crcBits);
39
+
40
+ const result = new Uint8Array(bits.length + crcBits);
41
+ result.set(bits);
42
+
43
+ // Append CRC bits (MSB first)
44
+ for (let i = 0; i < crcBits; i++) {
45
+ result[bits.length + i] = (crc >> (crcBits - 1 - i)) & 1;
46
+ }
47
+
48
+ return result;
49
+ }
50
+
51
+ /**
52
+ * Verify CRC on a bit array (last crcBits bits are the CRC)
53
+ * Returns the payload bits (without CRC) if valid, null otherwise
54
+ */
55
+ export function crcVerify(bits: Uint8Array, crcBits: 4 | 8 | 16): Uint8Array | null {
56
+ if (bits.length <= crcBits) return null;
57
+
58
+ const payload = bits.subarray(0, bits.length - crcBits);
59
+ const poly = crcBits === 4 ? CRC4_POLY : crcBits === 8 ? CRC8_POLY : CRC16_POLY;
60
+ const computed = computeCrc(payload, poly, crcBits);
61
+
62
+ // Extract received CRC
63
+ let received = 0;
64
+ for (let i = 0; i < crcBits; i++) {
65
+ received |= (bits[bits.length - crcBits + i] & 1) << (crcBits - 1 - i);
66
+ }
67
+
68
+ if (computed === received) {
69
+ return new Uint8Array(payload);
70
+ }
71
+ return null;
72
+ }
core/dct.ts ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 8x8 DCT forward and inverse transforms
3
+ * Separable implementation: rows then columns — O(N^3) instead of O(N^4)
4
+ * Reuses temp buffers to avoid per-call allocations
5
+ */
6
+
7
+ const N = 8;
8
+
9
+ // Precompute cosine table: cos((2i+1)*j*PI / 16) for i,j in [0,8)
10
+ const COS_TABLE = new Float64Array(N * N);
11
+ for (let i = 0; i < N; i++) {
12
+ for (let j = 0; j < N; j++) {
13
+ COS_TABLE[i * N + j] = Math.cos(((2 * i + 1) * j * Math.PI) / (2 * N));
14
+ }
15
+ }
16
+
17
+ // Precompute alpha normalization table: alpha(u) * alpha(v) for all (u,v)
18
+ const ALPHA_0 = 1 / Math.sqrt(N);
19
+ const ALPHA_K = Math.sqrt(2 / N);
20
+ const ALPHA_UV = new Float64Array(N * N);
21
+ const ALPHA = new Float64Array(N);
22
+ for (let k = 0; k < N; k++) ALPHA[k] = k === 0 ? ALPHA_0 : ALPHA_K;
23
+ for (let u = 0; u < N; u++) {
24
+ for (let v = 0; v < N; v++) {
25
+ ALPHA_UV[u * N + v] = ALPHA[u] * ALPHA[v];
26
+ }
27
+ }
28
+
29
+ // Precompute scaled cosine: ALPHA[k] * COS_TABLE[i*N+k] for forward transform
30
+ const SCALED_COS = new Float64Array(N * N);
31
+ for (let i = 0; i < N; i++) {
32
+ for (let k = 0; k < N; k++) {
33
+ SCALED_COS[i * N + k] = ALPHA[k] * COS_TABLE[i * N + k];
34
+ }
35
+ }
36
+
37
+ // Shared temp buffers (avoids allocation per call)
38
+ const _temp64 = new Float64Array(64);
39
+ const _temp8 = new Float64Array(8);
40
+
41
+ /**
42
+ * Forward 8x8 DCT on a block (in-place)
43
+ * Separable: transform rows, then columns — O(8^3) = 512 ops vs O(8^4) = 4096
44
+ */
45
+ export function dctForward8x8(block: Float64Array): void {
46
+ // Step 1: Transform each row (spatial x → frequency v)
47
+ for (let x = 0; x < N; x++) {
48
+ const rowOff = x * N;
49
+ for (let v = 0; v < N; v++) {
50
+ let sum = 0;
51
+ for (let y = 0; y < N; y++) {
52
+ sum += block[rowOff + y] * COS_TABLE[y * N + v];
53
+ }
54
+ _temp64[rowOff + v] = ALPHA[v] * sum;
55
+ }
56
+ }
57
+
58
+ // Step 2: Transform each column (spatial x → frequency u)
59
+ for (let v = 0; v < N; v++) {
60
+ for (let u = 0; u < N; u++) {
61
+ let sum = 0;
62
+ for (let x = 0; x < N; x++) {
63
+ sum += _temp64[x * N + v] * COS_TABLE[x * N + u];
64
+ }
65
+ block[u * N + v] = ALPHA[u] * sum;
66
+ }
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Inverse 8x8 DCT on a block (in-place)
72
+ * Separable: inverse columns, then inverse rows
73
+ */
74
+ export function dctInverse8x8(block: Float64Array): void {
75
+ // Step 1: Inverse transform columns (frequency u → spatial x)
76
+ for (let v = 0; v < N; v++) {
77
+ for (let x = 0; x < N; x++) {
78
+ let sum = 0;
79
+ for (let u = 0; u < N; u++) {
80
+ sum += ALPHA[u] * block[u * N + v] * COS_TABLE[x * N + u];
81
+ }
82
+ _temp64[x * N + v] = sum;
83
+ }
84
+ }
85
+
86
+ // Step 2: Inverse transform rows (frequency v → spatial y)
87
+ for (let x = 0; x < N; x++) {
88
+ const rowOff = x * N;
89
+ for (let y = 0; y < N; y++) {
90
+ let sum = 0;
91
+ for (let v = 0; v < N; v++) {
92
+ sum += ALPHA[v] * _temp64[rowOff + v] * COS_TABLE[y * N + v];
93
+ }
94
+ block[rowOff + y] = sum;
95
+ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Zigzag scan order for 8x8 block
101
+ * Maps zigzag index → [row, col]
102
+ */
103
+ export const ZIGZAG_ORDER: [number, number][] = [
104
+ [0,0],[0,1],[1,0],[2,0],[1,1],[0,2],[0,3],[1,2],
105
+ [2,1],[3,0],[4,0],[3,1],[2,2],[1,3],[0,4],[0,5],
106
+ [1,4],[2,3],[3,2],[4,1],[5,0],[6,0],[5,1],[4,2],
107
+ [3,3],[2,4],[1,5],[0,6],[0,7],[1,6],[2,5],[3,4],
108
+ [4,3],[5,2],[6,1],[7,0],[7,1],[6,2],[5,3],[4,4],
109
+ [3,5],[2,6],[1,7],[2,7],[3,6],[4,5],[5,4],[6,3],
110
+ [7,2],[7,3],[6,4],[5,5],[4,6],[3,7],[4,7],[5,6],
111
+ [6,5],[7,4],[7,5],[6,6],[5,7],[6,7],[7,6],[7,7],
112
+ ];
113
+
114
+ /**
115
+ * Extract an 8x8 block from a subband into a reusable buffer
116
+ */
117
+ export function extractBlock(
118
+ data: Float64Array,
119
+ width: number,
120
+ blockRow: number,
121
+ blockCol: number,
122
+ out?: Float64Array
123
+ ): Float64Array {
124
+ const block = out || new Float64Array(64);
125
+ const startY = blockRow * 8;
126
+ const startX = blockCol * 8;
127
+ for (let r = 0; r < 8; r++) {
128
+ const srcOff = (startY + r) * width + startX;
129
+ const dstOff = r * 8;
130
+ for (let c = 0; c < 8; c++) {
131
+ block[dstOff + c] = data[srcOff + c];
132
+ }
133
+ }
134
+ return block;
135
+ }
136
+
137
+ /**
138
+ * Write an 8x8 block back into a subband
139
+ */
140
+ export function writeBlock(
141
+ data: Float64Array,
142
+ width: number,
143
+ blockRow: number,
144
+ blockCol: number,
145
+ block: Float64Array
146
+ ): void {
147
+ const startY = blockRow * 8;
148
+ const startX = blockCol * 8;
149
+ for (let r = 0; r < 8; r++) {
150
+ const dstOff = (startY + r) * width + startX;
151
+ const srcOff = r * 8;
152
+ for (let c = 0; c < 8; c++) {
153
+ data[dstOff + c] = block[srcOff + c];
154
+ }
155
+ }
156
+ }
core/detector.ts ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * High-level watermark detector
3
+ *
4
+ * Takes a Y plane + key + config → returns detection result
5
+ */
6
+
7
+ import type { WatermarkConfig, DetectionResult, Buffer2D } from './types.js';
8
+ import { yPlaneToBuffer, dwtForward, extractSubband } from './dwt.js';
9
+ import { dctForward8x8, extractBlock, ZIGZAG_ORDER } from './dct.js';
10
+ import { dmqimExtractSoft } from './dmqim.js';
11
+ import { crcVerify } from './crc.js';
12
+ import { BchCodec } from './bch.js';
13
+ import { generateDithers, generatePermutation } from './keygen.js';
14
+ import { computeTileGrid, getTileOrigin, getTileBlocks, type TileGrid } from './tiling.js';
15
+ import { blockAcEnergy, computeMaskingFactors } from './masking.js';
16
+ import { bitsToPayload } from './embedder.js';
17
+ import { PRESETS } from './presets.js';
18
+ import type { PresetName } from './types.js';
19
+
20
+ /**
21
+ * Detect and extract watermark from a single Y plane
22
+ */
23
+ export function detectWatermark(
24
+ yPlane: Uint8Array,
25
+ width: number,
26
+ height: number,
27
+ key: string,
28
+ config: WatermarkConfig
29
+ ): DetectionResult {
30
+ return detectWatermarkMultiFrame([yPlane], width, height, key, config);
31
+ }
32
+
33
+ /**
34
+ * Extract per-tile soft decisions from a single Y plane.
35
+ * Returns an array of soft-bit vectors, one per tile.
36
+ */
37
+ /** Precomputed DWT subband + tile grid info for a frame */
38
+ interface FrameDWT {
39
+ hlSubband: Buffer2D;
40
+ subbandTilePeriod: number;
41
+ }
42
+
43
+ function computeFrameDWT(
44
+ yPlane: Uint8Array,
45
+ width: number,
46
+ height: number,
47
+ config: WatermarkConfig
48
+ ): FrameDWT {
49
+ const buf = yPlaneToBuffer(yPlane, width, height);
50
+ const { buf: dwtBuf, dims } = dwtForward(buf, config.dwtLevels);
51
+ const hlSubband = extractSubband(dwtBuf, dims[dims.length - 1].w, dims[dims.length - 1].h, 'HL');
52
+ const subbandTilePeriod = Math.floor(config.tilePeriod / (1 << config.dwtLevels));
53
+ return { hlSubband, subbandTilePeriod };
54
+ }
55
+
56
+ function extractSoftBitsFromSubband(
57
+ hlSubband: Buffer2D,
58
+ tileGrid: TileGrid,
59
+ key: string,
60
+ config: WatermarkConfig
61
+ ): { tileSoftBits: Float64Array[]; totalTiles: number } | null {
62
+ if (tileGrid.totalTiles === 0) return null;
63
+
64
+ const codedLength = config.bch.n;
65
+ const maxCoeffsPerTile = 1024;
66
+ const dithers = generateDithers(key, maxCoeffsPerTile, config.delta);
67
+
68
+ const tileSoftBits: Float64Array[] = [];
69
+ const blockBuf = new Float64Array(64);
70
+
71
+ // Precompute zigzag → coefficient index mapping
72
+ const zigCoeffIdx = new Int32Array(config.zigzagPositions.length);
73
+ for (let z = 0; z < config.zigzagPositions.length; z++) {
74
+ const [r, c] = ZIGZAG_ORDER[config.zigzagPositions[z]];
75
+ zigCoeffIdx[z] = r * 8 + c;
76
+ }
77
+
78
+ for (let tileIdx = 0; tileIdx < tileGrid.totalTiles; tileIdx++) {
79
+ let ditherIdx = 0; // Reset per tile — matches embedder
80
+ const origin = getTileOrigin(tileGrid, tileIdx);
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) {
88
+ const energies = new Float64Array(blocks.length);
89
+ for (let bi = 0; bi < blocks.length; bi++) {
90
+ extractBlock(hlSubband.data, hlSubband.width, blocks[bi].row, blocks[bi].col, blockBuf);
91
+ dctForward8x8(blockBuf);
92
+ energies[bi] = blockAcEnergy(blockBuf);
93
+ }
94
+ maskingFactors = computeMaskingFactors(energies);
95
+ }
96
+
97
+ let bitIdx = 0;
98
+ for (let bi = 0; bi < blocks.length; bi++) {
99
+ const { row, col } = blocks[bi];
100
+ extractBlock(hlSubband.data, hlSubband.width, row, col, blockBuf);
101
+ dctForward8x8(blockBuf);
102
+
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
+
109
+ const coeffIdx = zigCoeffIdx[z];
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);
125
+ }
126
+
127
+ return { tileSoftBits, totalTiles: tileGrid.totalTiles };
128
+ }
129
+
130
+ /**
131
+ * Detect watermark from multiple Y planes.
132
+ * Extracts soft decisions from each frame independently, then combines
133
+ * across frames and tiles (never averages raw pixels).
134
+ */
135
+ export function detectWatermarkMultiFrame(
136
+ yPlanes: Uint8Array[],
137
+ width: number,
138
+ height: number,
139
+ key: string,
140
+ config: WatermarkConfig,
141
+ ): DetectionResult {
142
+ const noResult: DetectionResult = {
143
+ detected: false,
144
+ payload: null,
145
+ confidence: 0,
146
+ tilesDecoded: 0,
147
+ tilesTotal: 0,
148
+ };
149
+
150
+ if (yPlanes.length === 0) return noResult;
151
+
152
+ const codedLength = config.bch.n;
153
+ const bch = new BchCodec(config.bch);
154
+ const perm = generatePermutation(key, codedLength);
155
+
156
+ // Helper: try to detect with given frames and explicit tile grid
157
+ const tryWithGrid = (
158
+ frames: FrameDWT[],
159
+ makeGrid: (hlSubband: Buffer2D, stp: number) => TileGrid,
160
+ ): DetectionResult | null => {
161
+ const softBits: Float64Array[] = [];
162
+ for (const { hlSubband, subbandTilePeriod } of frames) {
163
+ const tileGrid = makeGrid(hlSubband, subbandTilePeriod);
164
+ const frameResult = extractSoftBitsFromSubband(hlSubband, tileGrid, key, config);
165
+ if (frameResult) softBits.push(...frameResult.tileSoftBits);
166
+ }
167
+ if (softBits.length === 0) return null;
168
+ return decodeFromSoftBits(softBits, codedLength, perm, bch, config);
169
+ };
170
+
171
+ // Fast path: zero-phase grid (uncropped frames)
172
+ const frameDWTs = yPlanes.map((yp) => computeFrameDWT(yp, width, height, config));
173
+ const fast = tryWithGrid(frameDWTs, (hl, stp) =>
174
+ computeTileGrid(hl.width, hl.height, stp));
175
+ if (fast) return fast;
176
+
177
+ return noResult;
178
+ }
179
+
180
+ /**
181
+ * Combine soft bits from all tiles, decode, and compute confidence.
182
+ * Returns null if decoding fails or confidence is too low.
183
+ */
184
+ function decodeFromSoftBits(
185
+ allTileSoftBits: Float64Array[],
186
+ codedLength: number,
187
+ perm: Uint32Array,
188
+ bch: BchCodec,
189
+ config: WatermarkConfig
190
+ ): DetectionResult | null {
191
+ const combined = new Float64Array(codedLength);
192
+ for (const tileSoft of allTileSoftBits) {
193
+ for (let i = 0; i < codedLength; i++) {
194
+ combined[i] += tileSoft[i];
195
+ }
196
+ }
197
+ for (let i = 0; i < codedLength; i++) {
198
+ combined[i] /= allTileSoftBits.length;
199
+ }
200
+
201
+ const decoded = tryDecode(combined, perm, bch, config);
202
+ if (!decoded) return null;
203
+
204
+ // Cross-validate — count how many individual tiles agree with the combined decode
205
+ const reEncoded = bch.encode(decoded.rawMessage);
206
+ let agreeTiles = 0;
207
+
208
+ for (const tileSoft of allTileSoftBits) {
209
+ const deinterleaved = new Float64Array(codedLength);
210
+ for (let i = 0; i < codedLength; i++) {
211
+ deinterleaved[i] = tileSoft[perm[i]];
212
+ }
213
+ let matching = 0;
214
+ for (let i = 0; i < codedLength; i++) {
215
+ const hardBit = deinterleaved[i] > 0 ? 1 : 0;
216
+ if (hardBit === reEncoded[i]) matching++;
217
+ }
218
+ if (matching / codedLength > 0.65) agreeTiles++;
219
+ }
220
+
221
+ const totalTileCount = allTileSoftBits.length;
222
+ const zSingle = 0.3 * Math.sqrt(codedLength);
223
+ const pChance = Math.max(1e-10, 0.5 * Math.exp(-0.5 * zSingle * zSingle));
224
+
225
+ const expected = totalTileCount * pChance;
226
+ const stddev = Math.sqrt(totalTileCount * pChance * (1 - pChance));
227
+ const z = stddev > 0 ? (agreeTiles - expected) / stddev : agreeTiles > expected ? 100 : 0;
228
+ const statsConfidence = Math.max(0, Math.min(1.0, 1 - Math.exp(-z * 0.5)));
229
+
230
+ const confidence = Math.max(statsConfidence, decoded.softConfidence);
231
+
232
+ if (confidence < MIN_CONFIDENCE) return null;
233
+
234
+ return {
235
+ detected: true,
236
+ payload: decoded.payload,
237
+ confidence,
238
+ tilesDecoded: agreeTiles,
239
+ tilesTotal: allTileSoftBits.length,
240
+ };
241
+ }
242
+
243
+ /** Minimum confidence to report a detection (low threshold is fine —
244
+ * the statistical model already ensures noise scores near 0%) */
245
+ const MIN_CONFIDENCE = 0.10;
246
+
247
+ /**
248
+ * Try to decode soft bits into a payload
249
+ */
250
+ function tryDecode(
251
+ softBits: Float64Array,
252
+ perm: Uint32Array,
253
+ bch: BchCodec,
254
+ config: WatermarkConfig
255
+ ): { payload: Uint8Array; rawMessage: Uint8Array; softConfidence: number } | null {
256
+ const codedLength = config.bch.n;
257
+
258
+ // De-interleave
259
+ const deinterleaved = new Float64Array(codedLength);
260
+ for (let i = 0; i < codedLength; i++) {
261
+ deinterleaved[i] = softBits[perm[i]];
262
+ }
263
+
264
+ // BCH soft decode
265
+ const { message, reliable } = bch.decodeSoft(deinterleaved);
266
+ if (!message) return null;
267
+
268
+ // Extract the CRC-protected portion (first 32 + crc_bits of the BCH message)
269
+ const PAYLOAD_BITS = 32;
270
+ const crcProtectedLen = PAYLOAD_BITS + config.crc.bits;
271
+ const crcProtected = message.subarray(0, crcProtectedLen);
272
+
273
+ // CRC verify the 32-bit payload
274
+ const verified = crcVerify(crcProtected, config.crc.bits);
275
+ if (!verified) return null;
276
+
277
+ // Convert 32 payload bits to bytes
278
+ const payload = bitsToPayload(verified);
279
+
280
+ // Soft confidence = correlation between soft decisions and decoded codeword.
281
+ // Real signal: soft values are large AND agree with the codeword → high correlation.
282
+ // Noise that BCH happened to decode: soft values are small/random → low correlation.
283
+ const reEncoded = bch.encode(message);
284
+ let correlation = 0;
285
+ for (let i = 0; i < codedLength; i++) {
286
+ const sign = reEncoded[i] === 1 ? 1 : -1;
287
+ correlation += sign * deinterleaved[i];
288
+ }
289
+ correlation /= codedLength;
290
+ const softConfidence = Math.max(0, Math.min(1.0, correlation * 2));
291
+
292
+ return { payload, rawMessage: message, softConfidence };
293
+ }
294
+
295
+ /** Extended detection result that includes which preset matched */
296
+ export interface AutoDetectResult extends DetectionResult {
297
+ /** The preset that produced the detection (null if not detected) */
298
+ presetUsed: PresetName | null;
299
+ }
300
+
301
+ /**
302
+ * Auto-detect: try all presets and return the best result.
303
+ * No need for the user to know which preset was used during embedding.
304
+ */
305
+ export function autoDetect(
306
+ yPlane: Uint8Array,
307
+ width: number,
308
+ height: number,
309
+ key: string,
310
+ ): AutoDetectResult {
311
+ return autoDetectMultiFrame([yPlane], width, height, key);
312
+ }
313
+
314
+ /**
315
+ * Auto-detect with multiple frames: try all presets, return the best result.
316
+ */
317
+ export function autoDetectMultiFrame(
318
+ yPlanes: Uint8Array[],
319
+ width: number,
320
+ height: number,
321
+ key: string,
322
+ ): AutoDetectResult {
323
+ let best: AutoDetectResult = {
324
+ detected: false,
325
+ payload: null,
326
+ confidence: 0,
327
+ tilesDecoded: 0,
328
+ tilesTotal: 0,
329
+ presetUsed: null,
330
+ };
331
+
332
+ for (const [name, config] of Object.entries(PRESETS)) {
333
+ const result = detectWatermarkMultiFrame(yPlanes, width, height, key, config);
334
+ if (result.detected && result.confidence > best.confidence) {
335
+ best = { ...result, presetUsed: name as PresetName };
336
+ }
337
+ }
338
+
339
+ return best;
340
+ }
core/dmqim.ts ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Dither-Modulated Quantization Index Modulation (DM-QIM)
3
+ *
4
+ * Embeds a single bit into a coefficient using quantization.
5
+ * The dither value provides key-dependent randomization.
6
+ */
7
+
8
+ /**
9
+ * Embed a single bit into a coefficient using DM-QIM
10
+ *
11
+ * @param coeff - Original coefficient value
12
+ * @param bit - Bit to embed (0 or 1)
13
+ * @param delta - Quantization step size
14
+ * @param dither - Key-dependent dither value
15
+ * @returns Watermarked coefficient
16
+ */
17
+ export function dmqimEmbed(coeff: number, bit: number, delta: number, dither: number): number {
18
+ // Dither modulation: shift coefficient by dither before quantizing
19
+ const shifted = coeff - dither;
20
+
21
+ // Quantize to the nearest lattice point for the given bit
22
+ // Bit 0 → even quantization levels: ..., -2Δ, 0, 2Δ, ...
23
+ // Bit 1 → odd quantization levels: ..., -Δ, Δ, 3Δ, ...
24
+ const halfDelta = delta / 2;
25
+
26
+ if (bit === 0) {
27
+ // Quantize to nearest even multiple of delta
28
+ const quantized = Math.round(shifted / delta) * delta;
29
+ return quantized + dither;
30
+ } else {
31
+ // Quantize to nearest odd multiple of delta (offset by delta/2)
32
+ const quantized = Math.round((shifted - halfDelta) / delta) * delta + halfDelta;
33
+ return quantized + dither;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Extract a soft decision from a coefficient using DM-QIM
39
+ *
40
+ * Returns a signed float:
41
+ * Positive → likely bit 1
42
+ * Negative → likely bit 0
43
+ * Magnitude → confidence (closer to ±delta/4 = maximum confidence)
44
+ *
45
+ * @param coeff - Possibly watermarked coefficient
46
+ * @param delta - Quantization step size
47
+ * @param dither - Key-dependent dither value (must match embed)
48
+ * @returns Soft decision value in [-delta/4, +delta/4]
49
+ */
50
+ export function dmqimExtractSoft(coeff: number, delta: number, dither: number): number {
51
+ const shifted = coeff - dither;
52
+ const halfDelta = delta / 2;
53
+
54
+ // Distance to nearest even lattice point (bit 0)
55
+ const d0 = Math.abs(shifted - Math.round(shifted / delta) * delta);
56
+ // Distance to nearest odd lattice point (bit 1)
57
+ const d1 = Math.abs(shifted - halfDelta - Math.round((shifted - halfDelta) / delta) * delta);
58
+
59
+ // Soft output: positive for bit 1, negative for bit 0
60
+ // Magnitude reflects confidence
61
+ return (d0 - d1) / delta;
62
+ }
63
+
64
+ /**
65
+ * Extract a hard decision (0 or 1) from a coefficient
66
+ */
67
+ export function dmqimExtractHard(coeff: number, delta: number, dither: number): number {
68
+ return dmqimExtractSoft(coeff, delta, dither) > 0 ? 1 : 0;
69
+ }
core/dwt.ts ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Buffer2D, DwtResult } from './types.js';
2
+
3
+ /**
4
+ * Create a 2D buffer
5
+ */
6
+ export function createBuffer2D(width: number, height: number): Buffer2D {
7
+ return { data: new Float64Array(width * height), width, height };
8
+ }
9
+
10
+ /**
11
+ * Copy a Buffer2D
12
+ */
13
+ export function copyBuffer2D(buf: Buffer2D): Buffer2D {
14
+ return { data: new Float64Array(buf.data), width: buf.width, height: buf.height };
15
+ }
16
+
17
+ /**
18
+ * Get value from 2D buffer
19
+ */
20
+ function get(buf: Buffer2D, x: number, y: number): number {
21
+ return buf.data[y * buf.width + x];
22
+ }
23
+
24
+ /**
25
+ * Set value in 2D buffer
26
+ */
27
+ function set(buf: Buffer2D, x: number, y: number, val: number): void {
28
+ buf.data[y * buf.width + x] = val;
29
+ }
30
+
31
+ // Reusable temp buffer for Haar transforms — grown as needed
32
+ let _haarTmp: Float64Array | null = null;
33
+
34
+ function getHaarTmp(len: number): Float64Array {
35
+ if (!_haarTmp || _haarTmp.length < len) {
36
+ _haarTmp = new Float64Array(len);
37
+ }
38
+ return _haarTmp;
39
+ }
40
+
41
+ /**
42
+ * 1D Haar forward transform (in-place on a row/column slice)
43
+ */
44
+ function haarForward1D(input: Float64Array, length: number): void {
45
+ const half = length >> 1;
46
+ const tmp = getHaarTmp(length);
47
+ const s = Math.SQRT1_2; // 1/sqrt(2)
48
+ for (let i = 0; i < half; i++) {
49
+ const a = input[2 * i];
50
+ const b = input[2 * i + 1];
51
+ tmp[i] = (a + b) * s; // approximation (low)
52
+ tmp[half + i] = (a - b) * s; // detail (high)
53
+ }
54
+ for (let i = 0; i < length; i++) input[i] = tmp[i];
55
+ }
56
+
57
+ /**
58
+ * 1D Haar inverse transform (in-place on a row/column slice)
59
+ */
60
+ function haarInverse1D(input: Float64Array, length: number): void {
61
+ const half = length >> 1;
62
+ const tmp = getHaarTmp(length);
63
+ const s = Math.SQRT1_2;
64
+ for (let i = 0; i < half; i++) {
65
+ const low = input[i];
66
+ const high = input[half + i];
67
+ tmp[2 * i] = (low + high) * s;
68
+ tmp[2 * i + 1] = (low - high) * s;
69
+ }
70
+ for (let i = 0; i < length; i++) input[i] = tmp[i];
71
+ }
72
+
73
+ /**
74
+ * Forward 2D Haar DWT (one level)
75
+ * Transforms the top-left (w x h) region of the buffer
76
+ */
77
+ function dwtForward2DLevel(buf: Buffer2D, w: number, h: number): void {
78
+ // Transform rows
79
+ const rowBuf = new Float64Array(w);
80
+ for (let y = 0; y < h; y++) {
81
+ const off = y * buf.width;
82
+ for (let x = 0; x < w; x++) rowBuf[x] = buf.data[off + x];
83
+ haarForward1D(rowBuf, w);
84
+ for (let x = 0; x < w; x++) buf.data[off + x] = rowBuf[x];
85
+ }
86
+ // Transform columns
87
+ const colBuf = new Float64Array(h);
88
+ for (let x = 0; x < w; x++) {
89
+ for (let y = 0; y < h; y++) colBuf[y] = buf.data[y * buf.width + x];
90
+ haarForward1D(colBuf, h);
91
+ for (let y = 0; y < h; y++) buf.data[y * buf.width + x] = colBuf[y];
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Inverse 2D Haar DWT (one level)
97
+ */
98
+ function dwtInverse2DLevel(buf: Buffer2D, w: number, h: number): void {
99
+ // Inverse columns
100
+ const colBuf = new Float64Array(h);
101
+ for (let x = 0; x < w; x++) {
102
+ for (let y = 0; y < h; y++) colBuf[y] = buf.data[y * buf.width + x];
103
+ haarInverse1D(colBuf, h);
104
+ for (let y = 0; y < h; y++) buf.data[y * buf.width + x] = colBuf[y];
105
+ }
106
+ // Inverse rows
107
+ const rowBuf = new Float64Array(w);
108
+ for (let y = 0; y < h; y++) {
109
+ const off = y * buf.width;
110
+ for (let x = 0; x < w; x++) rowBuf[x] = buf.data[off + x];
111
+ haarInverse1D(rowBuf, w);
112
+ for (let x = 0; x < w; x++) buf.data[off + x] = rowBuf[x];
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Extract a subband from the DWT buffer
118
+ * After one level of DWT on a (w x h) region:
119
+ * LL = top-left (w/2 x h/2)
120
+ * LH = top-right (w/2 x h/2) — vertical detail
121
+ * HL = bottom-left (w/2 x h/2) — horizontal detail
122
+ * HH = bottom-right (w/2 x h/2) — diagonal detail
123
+ */
124
+ export function extractSubband(
125
+ buf: Buffer2D,
126
+ w: number,
127
+ h: number,
128
+ band: 'LL' | 'LH' | 'HL' | 'HH'
129
+ ): Buffer2D {
130
+ const hw = w >> 1;
131
+ const hh = h >> 1;
132
+ const offX = band === 'LH' || band === 'HH' ? hw : 0;
133
+ const offY = band === 'HL' || band === 'HH' ? hh : 0;
134
+ const out = createBuffer2D(hw, hh);
135
+ for (let y = 0; y < hh; y++) {
136
+ const srcOff = (offY + y) * buf.width + offX;
137
+ const dstOff = y * hw;
138
+ for (let x = 0; x < hw; x++) {
139
+ out.data[dstOff + x] = buf.data[srcOff + x];
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /**
146
+ * Write a subband back into the DWT buffer
147
+ */
148
+ export function writeSubband(
149
+ buf: Buffer2D,
150
+ w: number,
151
+ h: number,
152
+ band: 'LL' | 'LH' | 'HL' | 'HH',
153
+ subband: Buffer2D
154
+ ): void {
155
+ const hw = w >> 1;
156
+ const hh = h >> 1;
157
+ const offX = band === 'LH' || band === 'HH' ? hw : 0;
158
+ const offY = band === 'HL' || band === 'HH' ? hh : 0;
159
+ for (let y = 0; y < hh; y++) {
160
+ const dstOff = (offY + y) * buf.width + offX;
161
+ const srcOff = y * hw;
162
+ for (let x = 0; x < hw; x++) {
163
+ buf.data[dstOff + x] = subband.data[srcOff + x];
164
+ }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Multi-level forward DWT
170
+ * Returns the modified buffer and subband info for reconstruction
171
+ */
172
+ export function dwtForward(input: Buffer2D, levels: number): { buf: Buffer2D; dims: Array<{ w: number; h: number }> } {
173
+ const buf = copyBuffer2D(input);
174
+ const dims: Array<{ w: number; h: number }> = [];
175
+ let w = buf.width;
176
+ let h = buf.height;
177
+
178
+ for (let l = 0; l < levels; l++) {
179
+ // Ensure even dimensions
180
+ w = w & ~1;
181
+ h = h & ~1;
182
+ dims.push({ w, h });
183
+ dwtForward2DLevel(buf, w, h);
184
+ w >>= 1;
185
+ h >>= 1;
186
+ }
187
+
188
+ return { buf, dims };
189
+ }
190
+
191
+ /**
192
+ * Multi-level inverse DWT
193
+ */
194
+ export function dwtInverse(buf: Buffer2D, dims: Array<{ w: number; h: number }>): void {
195
+ for (let l = dims.length - 1; l >= 0; l--) {
196
+ const { w, h } = dims[l];
197
+ dwtInverse2DLevel(buf, w, h);
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Convert a Uint8Array Y plane to a Float64 Buffer2D
203
+ */
204
+ export function yPlaneToBuffer(yPlane: Uint8Array, width: number, height: number): Buffer2D {
205
+ const buf = createBuffer2D(width, height);
206
+ for (let i = 0; i < width * height; i++) {
207
+ buf.data[i] = yPlane[i];
208
+ }
209
+ return buf;
210
+ }
211
+
212
+ /**
213
+ * Convert a Float64 Buffer2D back to a Uint8Array Y plane (clamped to 0–255)
214
+ */
215
+ export function bufferToYPlane(buf: Buffer2D): Uint8Array {
216
+ const out = new Uint8Array(buf.width * buf.height);
217
+ for (let i = 0; i < out.length; i++) {
218
+ out[i] = Math.max(0, Math.min(255, Math.round(buf.data[i])));
219
+ }
220
+ return out;
221
+ }
core/embedder.ts ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * High-level watermark embedder
3
+ *
4
+ * Takes a Y plane + payload + key + config → returns watermarked Y plane
5
+ */
6
+
7
+ import type { WatermarkConfig, EmbedResult, Buffer2D } from './types.js';
8
+ import { yPlaneToBuffer, bufferToYPlane, dwtForward, dwtInverse, extractSubband, writeSubband } from './dwt.js';
9
+ import { dctForward8x8, dctInverse8x8, extractBlock, writeBlock, ZIGZAG_ORDER } from './dct.js';
10
+ import { dmqimEmbed } from './dmqim.js';
11
+ import { crcAppend } from './crc.js';
12
+ import { BchCodec } from './bch.js';
13
+ import { generateDithers, generatePermutation } from './keygen.js';
14
+ import { computeTileGrid, getTileOrigin, getTileBlocks } from './tiling.js';
15
+ import { blockAcEnergy, computeMaskingFactors } from './masking.js';
16
+
17
+ /**
18
+ * Convert a 32-bit payload (4 bytes) to a bit array
19
+ */
20
+ export function payloadToBits(payload: Uint8Array): Uint8Array {
21
+ const bits = new Uint8Array(payload.length * 8);
22
+ for (let i = 0; i < payload.length; i++) {
23
+ for (let b = 0; b < 8; b++) {
24
+ bits[i * 8 + b] = (payload[i] >> (7 - b)) & 1;
25
+ }
26
+ }
27
+ return bits;
28
+ }
29
+
30
+ /**
31
+ * Convert a bit array back to bytes
32
+ */
33
+ export function bitsToPayload(bits: Uint8Array): Uint8Array {
34
+ const bytes = new Uint8Array(Math.ceil(bits.length / 8));
35
+ for (let i = 0; i < bits.length; i++) {
36
+ if (bits[i]) {
37
+ bytes[Math.floor(i / 8)] |= 1 << (7 - (i % 8));
38
+ }
39
+ }
40
+ return bytes;
41
+ }
42
+
43
+ /**
44
+ * Embed a watermark into a Y plane
45
+ */
46
+ export function embedWatermark(
47
+ yPlane: Uint8Array,
48
+ width: number,
49
+ height: number,
50
+ payload: Uint8Array,
51
+ key: string,
52
+ config: WatermarkConfig
53
+ ): EmbedResult {
54
+ // Step 1: Encode payload
55
+ // Always use exactly 32 bits of payload, zero-pad the BCH message to fill k
56
+ const PAYLOAD_BITS = 32;
57
+ let payloadBits = payloadToBits(payload);
58
+ // Truncate or pad to exactly 32 bits
59
+ if (payloadBits.length < PAYLOAD_BITS) {
60
+ const padded = new Uint8Array(PAYLOAD_BITS);
61
+ padded.set(payloadBits);
62
+ payloadBits = padded;
63
+ } else if (payloadBits.length > PAYLOAD_BITS) {
64
+ payloadBits = payloadBits.subarray(0, PAYLOAD_BITS);
65
+ }
66
+ // CRC protects the 32-bit payload
67
+ const withCrc = crcAppend(payloadBits, config.crc.bits);
68
+ // Pad to fill BCH message length k
69
+ const bchMessage = new Uint8Array(config.bch.k);
70
+ bchMessage.set(withCrc);
71
+ const bch = new BchCodec(config.bch);
72
+ const encoded = bch.encode(bchMessage);
73
+ const codedLength = encoded.length;
74
+
75
+ // Generate interleaving permutation
76
+ const perm = generatePermutation(key, codedLength);
77
+ const interleaved = new Uint8Array(codedLength);
78
+ for (let i = 0; i < codedLength; i++) {
79
+ interleaved[perm[i]] = encoded[i];
80
+ }
81
+
82
+ // Step 2: Forward DWT
83
+ const buf = yPlaneToBuffer(yPlane, width, height);
84
+ const { buf: dwtBuf, dims } = dwtForward(buf, config.dwtLevels);
85
+
86
+ // Step 3: Extract HL subband at deepest level
87
+ // After N levels, the HL subband is in the bottom-left of the level-N region
88
+ let w = dims[dims.length - 1].w;
89
+ let h = dims[dims.length - 1].h;
90
+ for (let l = 0; l < config.dwtLevels - 1; l++) {
91
+ w >>= 1;
92
+ h >>= 1;
93
+ }
94
+ const hlSubband = extractSubband(dwtBuf, dims[dims.length - 1].w, dims[dims.length - 1].h, 'HL');
95
+
96
+ // Step 4: Set up tile grid
97
+ // tilePeriod is in spatial pixels; in subband it's tilePeriod / (2^levels)
98
+ const subbandTilePeriod = Math.floor(config.tilePeriod / (1 << config.dwtLevels));
99
+ const tileGrid = computeTileGrid(hlSubband.width, hlSubband.height, subbandTilePeriod);
100
+
101
+ if (tileGrid.totalTiles === 0) {
102
+ // Frame too small for any tiles — return unchanged
103
+ return { yPlane: new Uint8Array(yPlane), psnr: Infinity };
104
+ }
105
+
106
+ // Step 5: Generate dithers — same sequence reused per tile so each tile
107
+ // is independently decodable (required for crop robustness)
108
+ const coeffsPerBlock = config.zigzagPositions.length;
109
+ const maxCoeffsPerTile = 1024; // Upper bound
110
+ const dithers = generateDithers(key, maxCoeffsPerTile, config.delta);
111
+
112
+ // Step 6: For each tile, embed the coded bits
113
+ // Reusable block buffer to avoid allocation per block
114
+ const blockBuf = new Float64Array(64);
115
+
116
+ // Precompute zigzag → coefficient index mapping
117
+ const zigCoeffIdx = new Int32Array(config.zigzagPositions.length);
118
+ for (let z = 0; z < config.zigzagPositions.length; z++) {
119
+ const [r, c] = ZIGZAG_ORDER[config.zigzagPositions[z]];
120
+ zigCoeffIdx[z] = r * 8 + c;
121
+ }
122
+
123
+ for (let tileIdx = 0; tileIdx < tileGrid.totalTiles; tileIdx++) {
124
+ let ditherIdx = 0; // Reset per tile — each tile uses same dither sequence
125
+ const origin = getTileOrigin(tileGrid, tileIdx);
126
+ const blocks = getTileBlocks(origin.x, origin.y, subbandTilePeriod, hlSubband.width, hlSubband.height);
127
+
128
+ // Compute masking factors if enabled
129
+ let maskingFactors: Float64Array | null = null;
130
+ if (config.perceptualMasking && blocks.length > 0) {
131
+ const energies = new Float64Array(blocks.length);
132
+ for (let bi = 0; bi < blocks.length; bi++) {
133
+ extractBlock(hlSubband.data, hlSubband.width, blocks[bi].row, blocks[bi].col, blockBuf);
134
+ dctForward8x8(blockBuf);
135
+ energies[bi] = blockAcEnergy(blockBuf);
136
+ }
137
+ maskingFactors = computeMaskingFactors(energies);
138
+ }
139
+
140
+ let bitIdx = 0;
141
+ for (let bi = 0; bi < blocks.length; bi++) {
142
+ const { row, col } = blocks[bi];
143
+ extractBlock(hlSubband.data, hlSubband.width, row, col, blockBuf);
144
+
145
+ // Forward DCT
146
+ dctForward8x8(blockBuf);
147
+
148
+ const maskFactor = maskingFactors ? maskingFactors[bi] : 1.0;
149
+ const effectiveDelta = config.delta * maskFactor;
150
+
151
+ // Embed bits into selected zigzag positions
152
+ for (let z = 0; z < zigCoeffIdx.length; z++) {
153
+ if (bitIdx >= codedLength) bitIdx = 0; // Repeat pattern
154
+
155
+ const coeffIdx = zigCoeffIdx[z];
156
+ const bit = interleaved[bitIdx];
157
+ const dither = dithers[ditherIdx++];
158
+
159
+ blockBuf[coeffIdx] = dmqimEmbed(blockBuf[coeffIdx], bit, effectiveDelta, dither);
160
+
161
+ bitIdx++;
162
+ }
163
+
164
+ // Inverse DCT and write back
165
+ dctInverse8x8(blockBuf);
166
+ writeBlock(hlSubband.data, hlSubband.width, row, col, blockBuf);
167
+ }
168
+ }
169
+
170
+ // Step 7: Write modified HL subband back and inverse DWT
171
+ writeSubband(dwtBuf, dims[dims.length - 1].w, dims[dims.length - 1].h, 'HL', hlSubband);
172
+ dwtInverse(dwtBuf, dims);
173
+
174
+ // Convert back to Y plane
175
+ const watermarkedY = bufferToYPlane(dwtBuf);
176
+
177
+ // Compute PSNR
178
+ let mse = 0;
179
+ for (let i = 0; i < yPlane.length; i++) {
180
+ const diff = yPlane[i] - watermarkedY[i];
181
+ mse += diff * diff;
182
+ }
183
+ mse /= yPlane.length;
184
+ const psnr = mse > 0 ? 10 * Math.log10(255 * 255 / mse) : Infinity;
185
+
186
+ return { yPlane: watermarkedY, psnr };
187
+ }
core/keygen.ts ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Key derivation and PRNG for watermark embedding
3
+ *
4
+ * Uses a simple but effective approach: derive deterministic
5
+ * pseudo-random sequences from a secret key using a seeded PRNG.
6
+ * For browser compatibility, we use a pure-JS implementation.
7
+ */
8
+
9
+ /**
10
+ * Simple hash function (djb2 variant) for string to 32-bit seed
11
+ */
12
+ function hashString(str: string): number {
13
+ let hash = 5381;
14
+ for (let i = 0; i < str.length; i++) {
15
+ hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
16
+ }
17
+ return hash >>> 0;
18
+ }
19
+
20
+ /**
21
+ * Mix two 32-bit values (MurmurHash3 finalizer)
22
+ */
23
+ function mix(h: number): number {
24
+ h = ((h >>> 16) ^ h) * 0x45d9f3b | 0;
25
+ h = ((h >>> 16) ^ h) * 0x45d9f3b | 0;
26
+ h = (h >>> 16) ^ h;
27
+ return h >>> 0;
28
+ }
29
+
30
+ /**
31
+ * Seeded PRNG (xorshift128)
32
+ * Produces deterministic sequences from a seed
33
+ */
34
+ export class SeededPRNG {
35
+ private s0: number;
36
+ private s1: number;
37
+ private s2: number;
38
+ private s3: number;
39
+
40
+ constructor(seed: number) {
41
+ // Initialize state from seed using splitmix32
42
+ this.s0 = seed | 0;
43
+ this.s1 = mix(seed + 1);
44
+ this.s2 = mix(seed + 2);
45
+ this.s3 = mix(seed + 3);
46
+
47
+ // Warm up
48
+ for (let i = 0; i < 20; i++) this.next();
49
+ }
50
+
51
+ /** Get next random 32-bit unsigned integer */
52
+ next(): number {
53
+ let t = this.s3;
54
+ const s = this.s0;
55
+ this.s3 = this.s2;
56
+ this.s2 = this.s1;
57
+ this.s1 = s;
58
+ t ^= t << 11;
59
+ t ^= t >>> 8;
60
+ this.s0 = t ^ s ^ (s >>> 19);
61
+ return this.s0 >>> 0;
62
+ }
63
+
64
+ /** Get random float in [0, 1) */
65
+ nextFloat(): number {
66
+ return this.next() / 0x100000000;
67
+ }
68
+
69
+ /** Get random float in [min, max) */
70
+ nextRange(min: number, max: number): number {
71
+ return min + this.nextFloat() * (max - min);
72
+ }
73
+
74
+ /** Get random integer in [0, max) */
75
+ nextInt(max: number): number {
76
+ return (this.next() % max) >>> 0;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Derive a seed from key + purpose string
82
+ */
83
+ export function deriveSeed(key: string, purpose: string): number {
84
+ const combined = key + ':' + purpose;
85
+ return mix(hashString(combined));
86
+ }
87
+
88
+ /**
89
+ * Generate dither values for DM-QIM embedding
90
+ * Returns array of dither values in [-delta/2, +delta/2]
91
+ */
92
+ export function generateDithers(key: string, count: number, delta: number): Float64Array {
93
+ const prng = new SeededPRNG(deriveSeed(key, 'dither'));
94
+ const dithers = new Float64Array(count);
95
+ for (let i = 0; i < count; i++) {
96
+ dithers[i] = prng.nextRange(-delta / 2, delta / 2);
97
+ }
98
+ return dithers;
99
+ }
100
+
101
+ /**
102
+ * Generate a permutation (Fisher-Yates) for bit interleaving
103
+ */
104
+ export function generatePermutation(key: string, length: number): Uint32Array {
105
+ const prng = new SeededPRNG(deriveSeed(key, 'permutation'));
106
+ const perm = new Uint32Array(length);
107
+ for (let i = 0; i < length; i++) perm[i] = i;
108
+
109
+ for (let i = length - 1; i > 0; i--) {
110
+ const j = prng.nextInt(i + 1);
111
+ const tmp = perm[i];
112
+ perm[i] = perm[j];
113
+ perm[j] = tmp;
114
+ }
115
+
116
+ return perm;
117
+ }
118
+
119
+ /**
120
+ * Generate inverse permutation
121
+ */
122
+ export function generateInversePermutation(key: string, length: number): Uint32Array {
123
+ const perm = generatePermutation(key, length);
124
+ const inv = new Uint32Array(length);
125
+ for (let i = 0; i < length; i++) {
126
+ inv[perm[i]] = i;
127
+ }
128
+ return inv;
129
+ }
core/masking.ts ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Perceptual masking — adapt watermark strength based on local image content
3
+ *
4
+ * High-energy (textured) areas can tolerate stronger watermarks,
5
+ * while smooth areas need weaker embedding to remain imperceptible.
6
+ */
7
+
8
+ /**
9
+ * Compute AC energy of an 8x8 DCT block (sum of squared AC coefficients)
10
+ * Assumes the block is already in DCT domain.
11
+ */
12
+ export function blockAcEnergy(dctBlock: Float64Array): number {
13
+ let energy = 0;
14
+ for (let i = 1; i < 64; i++) { // Skip DC (index 0)
15
+ energy += dctBlock[i] * dctBlock[i];
16
+ }
17
+ return energy;
18
+ }
19
+
20
+ /**
21
+ * Compute perceptual masking factors for a set of DCT blocks
22
+ *
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
32
+ */
33
+ export function computeMaskingFactors(blockEnergies: Float64Array): Float64Array {
34
+ const n = blockEnergies.length;
35
+ if (n === 0) return new Float64Array(0);
36
+
37
+ // Compute median energy
38
+ const sorted = new Float64Array(blockEnergies).sort();
39
+ const median = n % 2 === 0
40
+ ? (sorted[n / 2 - 1] + sorted[n / 2]) / 2
41
+ : sorted[Math.floor(n / 2)];
42
+
43
+ // Avoid division by zero
44
+ const safeMedian = Math.max(median, 1e-6);
45
+
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;
54
+ }
core/presets.ts ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { PresetName, WatermarkConfig } from './types.js';
2
+
3
+ /** Mid-frequency DCT coefficients (zigzag positions 3–14, 12 coeffs) */
4
+ const MID_FREQ_ZIGZAG = [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14];
5
+
6
+ /** Low+mid frequency coefficients (zigzag 1–20, 20 coeffs) — survive heavy compression */
7
+ const LOW_MID_FREQ_ZIGZAG = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20];
8
+
9
+ /** Low frequency coefficients (zigzag 1–10, 10 coeffs) — survive extreme compression */
10
+ const LOW_FREQ_ZIGZAG = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
11
+
12
+ export const PRESETS: Record<PresetName, WatermarkConfig> = {
13
+ light: {
14
+ alpha: 0.10,
15
+ delta: 50,
16
+ tilePeriod: 256,
17
+ bch: { n: 63, k: 36, t: 5, m: 6 },
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,
32
+ zigzagPositions: MID_FREQ_ZIGZAG,
33
+ perceptualMasking: true,
34
+ temporalFrames: 1,
35
+ },
36
+
37
+ strong: {
38
+ alpha: 0.26,
39
+ delta: 110,
40
+ tilePeriod: 208,
41
+ bch: { n: 63, k: 36, t: 5, m: 6 },
42
+ crc: { bits: 4 },
43
+ dwtLevels: 2,
44
+ zigzagPositions: LOW_MID_FREQ_ZIGZAG,
45
+ perceptualMasking: true,
46
+ temporalFrames: 1,
47
+ },
48
+
49
+ fortress: {
50
+ alpha: 0.35,
51
+ delta: 150,
52
+ tilePeriod: 192,
53
+ bch: { n: 63, k: 36, t: 5, m: 6 },
54
+ crc: { bits: 4 },
55
+ dwtLevels: 2,
56
+ zigzagPositions: LOW_MID_FREQ_ZIGZAG,
57
+ perceptualMasking: true,
58
+ temporalFrames: 1,
59
+ },
60
+ };
61
+
62
+ /**
63
+ * Get a preset configuration by name
64
+ */
65
+ export function getPreset(name: PresetName): WatermarkConfig {
66
+ return { ...PRESETS[name] };
67
+ }
68
+
69
+ /**
70
+ * Preset descriptions for the UI
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
+ };
core/tiling.ts ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tile layout and autocorrelation-based sync recovery
3
+ *
4
+ * The watermark is embedded as a periodic pattern of tiles.
5
+ * Each tile contains one complete copy of the coded payload.
6
+ * During detection, autocorrelation recovers the tile grid
7
+ * even after cropping.
8
+ */
9
+
10
+ import type { Buffer2D } from './types.js';
11
+
12
+ /** Tile grid description */
13
+ export interface TileGrid {
14
+ /** Tile period in subband pixels */
15
+ tilePeriod: number;
16
+ /** Phase offset X (for cropped frames) */
17
+ phaseX: number;
18
+ /** Phase offset Y (for cropped frames) */
19
+ phaseY: number;
20
+ /** Number of complete tiles in X direction */
21
+ tilesX: number;
22
+ /** Number of complete tiles in Y direction */
23
+ tilesY: number;
24
+ /** Total number of tiles */
25
+ totalTiles: number;
26
+ }
27
+
28
+ /**
29
+ * Compute the tile grid for a given subband size and tile period
30
+ * During embedding, phase is always (0, 0)
31
+ */
32
+ export function computeTileGrid(
33
+ subbandWidth: number,
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,
44
+ tilesY,
45
+ totalTiles: tilesX * tilesY,
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Get the subband region for a specific tile
51
+ * Returns [startX, startY] in subband coordinates
52
+ */
53
+ export function getTileOrigin(grid: TileGrid, tileIdx: number): { x: number; y: number } {
54
+ const tileCol = tileIdx % grid.tilesX;
55
+ const tileRow = Math.floor(tileIdx / grid.tilesX);
56
+ return {
57
+ x: grid.phaseX + tileCol * grid.tilePeriod,
58
+ y: grid.phaseY + tileRow * grid.tilePeriod,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Compute 8x8 DCT block positions within a tile
64
+ * Returns array of [blockRow, blockCol] in subband coordinates
65
+ */
66
+ export function getTileBlocks(
67
+ tileOriginX: number,
68
+ tileOriginY: number,
69
+ tilePeriod: number,
70
+ subbandWidth: number,
71
+ subbandHeight: number
72
+ ): Array<{ row: number; col: number }> {
73
+ const blocks: Array<{ row: number; col: number }> = [];
74
+ const blocksPerTileSide = Math.floor(tilePeriod / 8);
75
+
76
+ for (let br = 0; br < blocksPerTileSide; br++) {
77
+ for (let bc = 0; bc < blocksPerTileSide; bc++) {
78
+ const absRow = Math.floor(tileOriginY / 8) + br;
79
+ const absCol = Math.floor(tileOriginX / 8) + bc;
80
+ // Ensure the block fits within the subband
81
+ if ((absRow + 1) * 8 <= subbandHeight && (absCol + 1) * 8 <= subbandWidth) {
82
+ blocks.push({ row: absRow, col: absCol });
83
+ }
84
+ }
85
+ }
86
+
87
+ return blocks;
88
+ }
89
+
90
+ /**
91
+ * Autocorrelation-based tile period and phase recovery
92
+ *
93
+ * Computes 2D autocorrelation of the subband energy pattern
94
+ * to find the periodic tile structure.
95
+ */
96
+ export function recoverTileGrid(
97
+ subband: Buffer2D,
98
+ expectedTilePeriod: number,
99
+ searchRange: number = 4
100
+ ): TileGrid {
101
+ const { data, width, height } = subband;
102
+
103
+ // Compute block energy map (8x8 blocks)
104
+ const bw = Math.floor(width / 8);
105
+ const bh = Math.floor(height / 8);
106
+ const energy = new Float64Array(bw * bh);
107
+
108
+ for (let by = 0; by < bh; by++) {
109
+ for (let bx = 0; bx < bw; bx++) {
110
+ let e = 0;
111
+ for (let r = 0; r < 8; r++) {
112
+ for (let c = 0; c < 8; c++) {
113
+ const v = data[(by * 8 + r) * width + (bx * 8 + c)];
114
+ e += v * v;
115
+ }
116
+ }
117
+ energy[by * bw + bx] = e;
118
+ }
119
+ }
120
+
121
+ // Expected tile period in blocks
122
+ const expectedBlockPeriod = Math.floor(expectedTilePeriod / 8);
123
+
124
+ // Search for the best period near the expected value
125
+ let bestPeriod = expectedBlockPeriod;
126
+ let bestCorr = -Infinity;
127
+ let bestPhaseX = 0;
128
+ let bestPhaseY = 0;
129
+
130
+ for (let p = expectedBlockPeriod - searchRange; p <= expectedBlockPeriod + searchRange; p++) {
131
+ if (p < 2) continue;
132
+
133
+ // For each candidate phase offset
134
+ for (let py = 0; py < p; py++) {
135
+ for (let px = 0; px < p; px++) {
136
+ let corr = 0;
137
+ let count = 0;
138
+
139
+ // Compute autocorrelation at this period and phase
140
+ for (let by = py; by + p < bh; by += p) {
141
+ for (let bx = px; bx + p < bw; bx += p) {
142
+ const e1 = energy[by * bw + bx];
143
+ const e2 = energy[(by + p) * bw + bx];
144
+ const e3 = energy[by * bw + (bx + p)];
145
+ corr += e1 * e2 + e1 * e3;
146
+ count++;
147
+ }
148
+ }
149
+
150
+ if (count > 0) {
151
+ corr /= count;
152
+ if (corr > bestCorr) {
153
+ bestCorr = corr;
154
+ bestPeriod = p;
155
+ bestPhaseX = px;
156
+ bestPhaseY = py;
157
+ }
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ // Convert from block coordinates back to subband pixels
164
+ const tilePeriod = bestPeriod * 8;
165
+ const phaseX = bestPhaseX * 8;
166
+ const phaseY = bestPhaseY * 8;
167
+ const tilesX = Math.floor((width - phaseX) / tilePeriod);
168
+ const tilesY = Math.floor((height - phaseY) / tilePeriod);
169
+
170
+ return {
171
+ tilePeriod,
172
+ phaseX,
173
+ phaseY,
174
+ tilesX,
175
+ tilesY,
176
+ totalTiles: tilesX * tilesY,
177
+ };
178
+ }
core/types.ts ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Watermark preset names */
2
+ export type PresetName = 'light' | 'moderate' | 'strong' | 'fortress';
3
+
4
+ /** BCH code parameters */
5
+ export interface BchParams {
6
+ /** Codeword length */
7
+ n: number;
8
+ /** Message length */
9
+ k: number;
10
+ /** Error correction capability */
11
+ t: number;
12
+ /** Galois field order m (GF(2^m)) */
13
+ m: number;
14
+ }
15
+
16
+ /** CRC configuration */
17
+ export interface CrcConfig {
18
+ /** CRC bit width (4, 8, or 16) */
19
+ bits: 4 | 8 | 16;
20
+ }
21
+
22
+ /** Full watermark configuration */
23
+ export interface WatermarkConfig {
24
+ /** Embedding strength multiplier (0–1) */
25
+ alpha: number;
26
+ /** QIM quantization step */
27
+ delta: number;
28
+ /** Tile period in pixels (spatial domain) */
29
+ tilePeriod: number;
30
+ /** BCH code parameters */
31
+ bch: BchParams;
32
+ /** CRC configuration */
33
+ crc: CrcConfig;
34
+ /** DWT decomposition levels */
35
+ dwtLevels: number;
36
+ /** Zigzag coefficient positions to use within 8x8 DCT blocks */
37
+ zigzagPositions: number[];
38
+ /** Enable perceptual masking */
39
+ perceptualMasking: boolean;
40
+ /** Number of frames to average for temporal detection (1 = single frame) */
41
+ temporalFrames: number;
42
+ }
43
+
44
+ /** Detection result */
45
+ export interface DetectionResult {
46
+ /** Whether a watermark was detected */
47
+ detected: boolean;
48
+ /** Decoded payload (null if not detected) */
49
+ payload: Uint8Array | null;
50
+ /** Detection confidence (0–1) */
51
+ confidence: number;
52
+ /** Number of tiles successfully decoded */
53
+ tilesDecoded: number;
54
+ /** Total tiles found */
55
+ tilesTotal: number;
56
+ }
57
+
58
+ /** Embedding result */
59
+ export interface EmbedResult {
60
+ /** Watermarked Y plane */
61
+ yPlane: Uint8Array;
62
+ /** PSNR of the watermarked frame relative to original (dB) */
63
+ psnr: number;
64
+ }
65
+
66
+ /** 2D buffer for signal processing (row-major Float64Array) */
67
+ export interface Buffer2D {
68
+ data: Float64Array;
69
+ width: number;
70
+ height: number;
71
+ }
72
+
73
+ /** DWT decomposition result */
74
+ export interface DwtResult {
75
+ /** All subbands at each level: [level][LL, LH, HL, HH] */
76
+ subbands: Buffer2D[][];
77
+ /** Original dimensions at each level */
78
+ dimensions: Array<{ width: number; height: number }>;
79
+ /** Number of decomposition levels */
80
+ levels: number;
81
+ }
package-lock.json ADDED
@@ -0,0 +1,2826 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "ltmarx",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "ltmarx",
9
+ "version": "1.0.0",
10
+ "license": "MIT",
11
+ "dependencies": {
12
+ "@ffmpeg/ffmpeg": "^0.12.15",
13
+ "@ffmpeg/util": "^0.12.2",
14
+ "react": "^19.2.4",
15
+ "react-dom": "^19.2.4"
16
+ },
17
+ "bin": {
18
+ "ltmarx": "dist/server/cli.js"
19
+ },
20
+ "devDependencies": {
21
+ "@tailwindcss/vite": "^4.2.1",
22
+ "@types/node": "^25.3.0",
23
+ "@types/react": "^19.2.14",
24
+ "@types/react-dom": "^19.2.3",
25
+ "@vitejs/plugin-react": "^5.1.4",
26
+ "tailwindcss": "^4.2.1",
27
+ "typescript": "^5.9.3",
28
+ "vite": "^7.3.1",
29
+ "vitest": "^4.0.18"
30
+ }
31
+ },
32
+ "node_modules/@babel/code-frame": {
33
+ "version": "7.29.0",
34
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
35
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
36
+ "dev": true,
37
+ "license": "MIT",
38
+ "dependencies": {
39
+ "@babel/helper-validator-identifier": "^7.28.5",
40
+ "js-tokens": "^4.0.0",
41
+ "picocolors": "^1.1.1"
42
+ },
43
+ "engines": {
44
+ "node": ">=6.9.0"
45
+ }
46
+ },
47
+ "node_modules/@babel/compat-data": {
48
+ "version": "7.29.0",
49
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
50
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
51
+ "dev": true,
52
+ "license": "MIT",
53
+ "engines": {
54
+ "node": ">=6.9.0"
55
+ }
56
+ },
57
+ "node_modules/@babel/core": {
58
+ "version": "7.29.0",
59
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
60
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
61
+ "dev": true,
62
+ "license": "MIT",
63
+ "dependencies": {
64
+ "@babel/code-frame": "^7.29.0",
65
+ "@babel/generator": "^7.29.0",
66
+ "@babel/helper-compilation-targets": "^7.28.6",
67
+ "@babel/helper-module-transforms": "^7.28.6",
68
+ "@babel/helpers": "^7.28.6",
69
+ "@babel/parser": "^7.29.0",
70
+ "@babel/template": "^7.28.6",
71
+ "@babel/traverse": "^7.29.0",
72
+ "@babel/types": "^7.29.0",
73
+ "@jridgewell/remapping": "^2.3.5",
74
+ "convert-source-map": "^2.0.0",
75
+ "debug": "^4.1.0",
76
+ "gensync": "^1.0.0-beta.2",
77
+ "json5": "^2.2.3",
78
+ "semver": "^6.3.1"
79
+ },
80
+ "engines": {
81
+ "node": ">=6.9.0"
82
+ },
83
+ "funding": {
84
+ "type": "opencollective",
85
+ "url": "https://opencollective.com/babel"
86
+ }
87
+ },
88
+ "node_modules/@babel/generator": {
89
+ "version": "7.29.1",
90
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
91
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
92
+ "dev": true,
93
+ "license": "MIT",
94
+ "dependencies": {
95
+ "@babel/parser": "^7.29.0",
96
+ "@babel/types": "^7.29.0",
97
+ "@jridgewell/gen-mapping": "^0.3.12",
98
+ "@jridgewell/trace-mapping": "^0.3.28",
99
+ "jsesc": "^3.0.2"
100
+ },
101
+ "engines": {
102
+ "node": ">=6.9.0"
103
+ }
104
+ },
105
+ "node_modules/@babel/helper-compilation-targets": {
106
+ "version": "7.28.6",
107
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
108
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
109
+ "dev": true,
110
+ "license": "MIT",
111
+ "dependencies": {
112
+ "@babel/compat-data": "^7.28.6",
113
+ "@babel/helper-validator-option": "^7.27.1",
114
+ "browserslist": "^4.24.0",
115
+ "lru-cache": "^5.1.1",
116
+ "semver": "^6.3.1"
117
+ },
118
+ "engines": {
119
+ "node": ">=6.9.0"
120
+ }
121
+ },
122
+ "node_modules/@babel/helper-globals": {
123
+ "version": "7.28.0",
124
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
125
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
126
+ "dev": true,
127
+ "license": "MIT",
128
+ "engines": {
129
+ "node": ">=6.9.0"
130
+ }
131
+ },
132
+ "node_modules/@babel/helper-module-imports": {
133
+ "version": "7.28.6",
134
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
135
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
136
+ "dev": true,
137
+ "license": "MIT",
138
+ "dependencies": {
139
+ "@babel/traverse": "^7.28.6",
140
+ "@babel/types": "^7.28.6"
141
+ },
142
+ "engines": {
143
+ "node": ">=6.9.0"
144
+ }
145
+ },
146
+ "node_modules/@babel/helper-module-transforms": {
147
+ "version": "7.28.6",
148
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
149
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
150
+ "dev": true,
151
+ "license": "MIT",
152
+ "dependencies": {
153
+ "@babel/helper-module-imports": "^7.28.6",
154
+ "@babel/helper-validator-identifier": "^7.28.5",
155
+ "@babel/traverse": "^7.28.6"
156
+ },
157
+ "engines": {
158
+ "node": ">=6.9.0"
159
+ },
160
+ "peerDependencies": {
161
+ "@babel/core": "^7.0.0"
162
+ }
163
+ },
164
+ "node_modules/@babel/helper-plugin-utils": {
165
+ "version": "7.28.6",
166
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
167
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
168
+ "dev": true,
169
+ "license": "MIT",
170
+ "engines": {
171
+ "node": ">=6.9.0"
172
+ }
173
+ },
174
+ "node_modules/@babel/helper-string-parser": {
175
+ "version": "7.27.1",
176
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
177
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
178
+ "dev": true,
179
+ "license": "MIT",
180
+ "engines": {
181
+ "node": ">=6.9.0"
182
+ }
183
+ },
184
+ "node_modules/@babel/helper-validator-identifier": {
185
+ "version": "7.28.5",
186
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
187
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
188
+ "dev": true,
189
+ "license": "MIT",
190
+ "engines": {
191
+ "node": ">=6.9.0"
192
+ }
193
+ },
194
+ "node_modules/@babel/helper-validator-option": {
195
+ "version": "7.27.1",
196
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
197
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
198
+ "dev": true,
199
+ "license": "MIT",
200
+ "engines": {
201
+ "node": ">=6.9.0"
202
+ }
203
+ },
204
+ "node_modules/@babel/helpers": {
205
+ "version": "7.28.6",
206
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
207
+ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
208
+ "dev": true,
209
+ "license": "MIT",
210
+ "dependencies": {
211
+ "@babel/template": "^7.28.6",
212
+ "@babel/types": "^7.28.6"
213
+ },
214
+ "engines": {
215
+ "node": ">=6.9.0"
216
+ }
217
+ },
218
+ "node_modules/@babel/parser": {
219
+ "version": "7.29.0",
220
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
221
+ "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
222
+ "dev": true,
223
+ "license": "MIT",
224
+ "dependencies": {
225
+ "@babel/types": "^7.29.0"
226
+ },
227
+ "bin": {
228
+ "parser": "bin/babel-parser.js"
229
+ },
230
+ "engines": {
231
+ "node": ">=6.0.0"
232
+ }
233
+ },
234
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
235
+ "version": "7.27.1",
236
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
237
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
238
+ "dev": true,
239
+ "license": "MIT",
240
+ "dependencies": {
241
+ "@babel/helper-plugin-utils": "^7.27.1"
242
+ },
243
+ "engines": {
244
+ "node": ">=6.9.0"
245
+ },
246
+ "peerDependencies": {
247
+ "@babel/core": "^7.0.0-0"
248
+ }
249
+ },
250
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
251
+ "version": "7.27.1",
252
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
253
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
254
+ "dev": true,
255
+ "license": "MIT",
256
+ "dependencies": {
257
+ "@babel/helper-plugin-utils": "^7.27.1"
258
+ },
259
+ "engines": {
260
+ "node": ">=6.9.0"
261
+ },
262
+ "peerDependencies": {
263
+ "@babel/core": "^7.0.0-0"
264
+ }
265
+ },
266
+ "node_modules/@babel/template": {
267
+ "version": "7.28.6",
268
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
269
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
270
+ "dev": true,
271
+ "license": "MIT",
272
+ "dependencies": {
273
+ "@babel/code-frame": "^7.28.6",
274
+ "@babel/parser": "^7.28.6",
275
+ "@babel/types": "^7.28.6"
276
+ },
277
+ "engines": {
278
+ "node": ">=6.9.0"
279
+ }
280
+ },
281
+ "node_modules/@babel/traverse": {
282
+ "version": "7.29.0",
283
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
284
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
285
+ "dev": true,
286
+ "license": "MIT",
287
+ "dependencies": {
288
+ "@babel/code-frame": "^7.29.0",
289
+ "@babel/generator": "^7.29.0",
290
+ "@babel/helper-globals": "^7.28.0",
291
+ "@babel/parser": "^7.29.0",
292
+ "@babel/template": "^7.28.6",
293
+ "@babel/types": "^7.29.0",
294
+ "debug": "^4.3.1"
295
+ },
296
+ "engines": {
297
+ "node": ">=6.9.0"
298
+ }
299
+ },
300
+ "node_modules/@babel/types": {
301
+ "version": "7.29.0",
302
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
303
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
304
+ "dev": true,
305
+ "license": "MIT",
306
+ "dependencies": {
307
+ "@babel/helper-string-parser": "^7.27.1",
308
+ "@babel/helper-validator-identifier": "^7.28.5"
309
+ },
310
+ "engines": {
311
+ "node": ">=6.9.0"
312
+ }
313
+ },
314
+ "node_modules/@esbuild/aix-ppc64": {
315
+ "version": "0.27.3",
316
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
317
+ "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
318
+ "cpu": [
319
+ "ppc64"
320
+ ],
321
+ "dev": true,
322
+ "license": "MIT",
323
+ "optional": true,
324
+ "os": [
325
+ "aix"
326
+ ],
327
+ "engines": {
328
+ "node": ">=18"
329
+ }
330
+ },
331
+ "node_modules/@esbuild/android-arm": {
332
+ "version": "0.27.3",
333
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
334
+ "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
335
+ "cpu": [
336
+ "arm"
337
+ ],
338
+ "dev": true,
339
+ "license": "MIT",
340
+ "optional": true,
341
+ "os": [
342
+ "android"
343
+ ],
344
+ "engines": {
345
+ "node": ">=18"
346
+ }
347
+ },
348
+ "node_modules/@esbuild/android-arm64": {
349
+ "version": "0.27.3",
350
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
351
+ "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
352
+ "cpu": [
353
+ "arm64"
354
+ ],
355
+ "dev": true,
356
+ "license": "MIT",
357
+ "optional": true,
358
+ "os": [
359
+ "android"
360
+ ],
361
+ "engines": {
362
+ "node": ">=18"
363
+ }
364
+ },
365
+ "node_modules/@esbuild/android-x64": {
366
+ "version": "0.27.3",
367
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
368
+ "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
369
+ "cpu": [
370
+ "x64"
371
+ ],
372
+ "dev": true,
373
+ "license": "MIT",
374
+ "optional": true,
375
+ "os": [
376
+ "android"
377
+ ],
378
+ "engines": {
379
+ "node": ">=18"
380
+ }
381
+ },
382
+ "node_modules/@esbuild/darwin-arm64": {
383
+ "version": "0.27.3",
384
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
385
+ "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
386
+ "cpu": [
387
+ "arm64"
388
+ ],
389
+ "dev": true,
390
+ "license": "MIT",
391
+ "optional": true,
392
+ "os": [
393
+ "darwin"
394
+ ],
395
+ "engines": {
396
+ "node": ">=18"
397
+ }
398
+ },
399
+ "node_modules/@esbuild/darwin-x64": {
400
+ "version": "0.27.3",
401
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
402
+ "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
403
+ "cpu": [
404
+ "x64"
405
+ ],
406
+ "dev": true,
407
+ "license": "MIT",
408
+ "optional": true,
409
+ "os": [
410
+ "darwin"
411
+ ],
412
+ "engines": {
413
+ "node": ">=18"
414
+ }
415
+ },
416
+ "node_modules/@esbuild/freebsd-arm64": {
417
+ "version": "0.27.3",
418
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
419
+ "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
420
+ "cpu": [
421
+ "arm64"
422
+ ],
423
+ "dev": true,
424
+ "license": "MIT",
425
+ "optional": true,
426
+ "os": [
427
+ "freebsd"
428
+ ],
429
+ "engines": {
430
+ "node": ">=18"
431
+ }
432
+ },
433
+ "node_modules/@esbuild/freebsd-x64": {
434
+ "version": "0.27.3",
435
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
436
+ "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
437
+ "cpu": [
438
+ "x64"
439
+ ],
440
+ "dev": true,
441
+ "license": "MIT",
442
+ "optional": true,
443
+ "os": [
444
+ "freebsd"
445
+ ],
446
+ "engines": {
447
+ "node": ">=18"
448
+ }
449
+ },
450
+ "node_modules/@esbuild/linux-arm": {
451
+ "version": "0.27.3",
452
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
453
+ "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
454
+ "cpu": [
455
+ "arm"
456
+ ],
457
+ "dev": true,
458
+ "license": "MIT",
459
+ "optional": true,
460
+ "os": [
461
+ "linux"
462
+ ],
463
+ "engines": {
464
+ "node": ">=18"
465
+ }
466
+ },
467
+ "node_modules/@esbuild/linux-arm64": {
468
+ "version": "0.27.3",
469
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
470
+ "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
471
+ "cpu": [
472
+ "arm64"
473
+ ],
474
+ "dev": true,
475
+ "license": "MIT",
476
+ "optional": true,
477
+ "os": [
478
+ "linux"
479
+ ],
480
+ "engines": {
481
+ "node": ">=18"
482
+ }
483
+ },
484
+ "node_modules/@esbuild/linux-ia32": {
485
+ "version": "0.27.3",
486
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
487
+ "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
488
+ "cpu": [
489
+ "ia32"
490
+ ],
491
+ "dev": true,
492
+ "license": "MIT",
493
+ "optional": true,
494
+ "os": [
495
+ "linux"
496
+ ],
497
+ "engines": {
498
+ "node": ">=18"
499
+ }
500
+ },
501
+ "node_modules/@esbuild/linux-loong64": {
502
+ "version": "0.27.3",
503
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
504
+ "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
505
+ "cpu": [
506
+ "loong64"
507
+ ],
508
+ "dev": true,
509
+ "license": "MIT",
510
+ "optional": true,
511
+ "os": [
512
+ "linux"
513
+ ],
514
+ "engines": {
515
+ "node": ">=18"
516
+ }
517
+ },
518
+ "node_modules/@esbuild/linux-mips64el": {
519
+ "version": "0.27.3",
520
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
521
+ "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
522
+ "cpu": [
523
+ "mips64el"
524
+ ],
525
+ "dev": true,
526
+ "license": "MIT",
527
+ "optional": true,
528
+ "os": [
529
+ "linux"
530
+ ],
531
+ "engines": {
532
+ "node": ">=18"
533
+ }
534
+ },
535
+ "node_modules/@esbuild/linux-ppc64": {
536
+ "version": "0.27.3",
537
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
538
+ "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
539
+ "cpu": [
540
+ "ppc64"
541
+ ],
542
+ "dev": true,
543
+ "license": "MIT",
544
+ "optional": true,
545
+ "os": [
546
+ "linux"
547
+ ],
548
+ "engines": {
549
+ "node": ">=18"
550
+ }
551
+ },
552
+ "node_modules/@esbuild/linux-riscv64": {
553
+ "version": "0.27.3",
554
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
555
+ "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
556
+ "cpu": [
557
+ "riscv64"
558
+ ],
559
+ "dev": true,
560
+ "license": "MIT",
561
+ "optional": true,
562
+ "os": [
563
+ "linux"
564
+ ],
565
+ "engines": {
566
+ "node": ">=18"
567
+ }
568
+ },
569
+ "node_modules/@esbuild/linux-s390x": {
570
+ "version": "0.27.3",
571
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
572
+ "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
573
+ "cpu": [
574
+ "s390x"
575
+ ],
576
+ "dev": true,
577
+ "license": "MIT",
578
+ "optional": true,
579
+ "os": [
580
+ "linux"
581
+ ],
582
+ "engines": {
583
+ "node": ">=18"
584
+ }
585
+ },
586
+ "node_modules/@esbuild/linux-x64": {
587
+ "version": "0.27.3",
588
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
589
+ "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
590
+ "cpu": [
591
+ "x64"
592
+ ],
593
+ "dev": true,
594
+ "license": "MIT",
595
+ "optional": true,
596
+ "os": [
597
+ "linux"
598
+ ],
599
+ "engines": {
600
+ "node": ">=18"
601
+ }
602
+ },
603
+ "node_modules/@esbuild/netbsd-arm64": {
604
+ "version": "0.27.3",
605
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
606
+ "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
607
+ "cpu": [
608
+ "arm64"
609
+ ],
610
+ "dev": true,
611
+ "license": "MIT",
612
+ "optional": true,
613
+ "os": [
614
+ "netbsd"
615
+ ],
616
+ "engines": {
617
+ "node": ">=18"
618
+ }
619
+ },
620
+ "node_modules/@esbuild/netbsd-x64": {
621
+ "version": "0.27.3",
622
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
623
+ "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
624
+ "cpu": [
625
+ "x64"
626
+ ],
627
+ "dev": true,
628
+ "license": "MIT",
629
+ "optional": true,
630
+ "os": [
631
+ "netbsd"
632
+ ],
633
+ "engines": {
634
+ "node": ">=18"
635
+ }
636
+ },
637
+ "node_modules/@esbuild/openbsd-arm64": {
638
+ "version": "0.27.3",
639
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
640
+ "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
641
+ "cpu": [
642
+ "arm64"
643
+ ],
644
+ "dev": true,
645
+ "license": "MIT",
646
+ "optional": true,
647
+ "os": [
648
+ "openbsd"
649
+ ],
650
+ "engines": {
651
+ "node": ">=18"
652
+ }
653
+ },
654
+ "node_modules/@esbuild/openbsd-x64": {
655
+ "version": "0.27.3",
656
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
657
+ "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
658
+ "cpu": [
659
+ "x64"
660
+ ],
661
+ "dev": true,
662
+ "license": "MIT",
663
+ "optional": true,
664
+ "os": [
665
+ "openbsd"
666
+ ],
667
+ "engines": {
668
+ "node": ">=18"
669
+ }
670
+ },
671
+ "node_modules/@esbuild/openharmony-arm64": {
672
+ "version": "0.27.3",
673
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
674
+ "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
675
+ "cpu": [
676
+ "arm64"
677
+ ],
678
+ "dev": true,
679
+ "license": "MIT",
680
+ "optional": true,
681
+ "os": [
682
+ "openharmony"
683
+ ],
684
+ "engines": {
685
+ "node": ">=18"
686
+ }
687
+ },
688
+ "node_modules/@esbuild/sunos-x64": {
689
+ "version": "0.27.3",
690
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
691
+ "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
692
+ "cpu": [
693
+ "x64"
694
+ ],
695
+ "dev": true,
696
+ "license": "MIT",
697
+ "optional": true,
698
+ "os": [
699
+ "sunos"
700
+ ],
701
+ "engines": {
702
+ "node": ">=18"
703
+ }
704
+ },
705
+ "node_modules/@esbuild/win32-arm64": {
706
+ "version": "0.27.3",
707
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
708
+ "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
709
+ "cpu": [
710
+ "arm64"
711
+ ],
712
+ "dev": true,
713
+ "license": "MIT",
714
+ "optional": true,
715
+ "os": [
716
+ "win32"
717
+ ],
718
+ "engines": {
719
+ "node": ">=18"
720
+ }
721
+ },
722
+ "node_modules/@esbuild/win32-ia32": {
723
+ "version": "0.27.3",
724
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
725
+ "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
726
+ "cpu": [
727
+ "ia32"
728
+ ],
729
+ "dev": true,
730
+ "license": "MIT",
731
+ "optional": true,
732
+ "os": [
733
+ "win32"
734
+ ],
735
+ "engines": {
736
+ "node": ">=18"
737
+ }
738
+ },
739
+ "node_modules/@esbuild/win32-x64": {
740
+ "version": "0.27.3",
741
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
742
+ "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
743
+ "cpu": [
744
+ "x64"
745
+ ],
746
+ "dev": true,
747
+ "license": "MIT",
748
+ "optional": true,
749
+ "os": [
750
+ "win32"
751
+ ],
752
+ "engines": {
753
+ "node": ">=18"
754
+ }
755
+ },
756
+ "node_modules/@ffmpeg/ffmpeg": {
757
+ "version": "0.12.15",
758
+ "resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz",
759
+ "integrity": "sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==",
760
+ "license": "MIT",
761
+ "dependencies": {
762
+ "@ffmpeg/types": "^0.12.4"
763
+ },
764
+ "engines": {
765
+ "node": ">=18.x"
766
+ }
767
+ },
768
+ "node_modules/@ffmpeg/types": {
769
+ "version": "0.12.4",
770
+ "resolved": "https://registry.npmjs.org/@ffmpeg/types/-/types-0.12.4.tgz",
771
+ "integrity": "sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==",
772
+ "license": "MIT",
773
+ "engines": {
774
+ "node": ">=16.x"
775
+ }
776
+ },
777
+ "node_modules/@ffmpeg/util": {
778
+ "version": "0.12.2",
779
+ "resolved": "https://registry.npmjs.org/@ffmpeg/util/-/util-0.12.2.tgz",
780
+ "integrity": "sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==",
781
+ "license": "MIT",
782
+ "engines": {
783
+ "node": ">=18.x"
784
+ }
785
+ },
786
+ "node_modules/@jridgewell/gen-mapping": {
787
+ "version": "0.3.13",
788
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
789
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
790
+ "dev": true,
791
+ "license": "MIT",
792
+ "dependencies": {
793
+ "@jridgewell/sourcemap-codec": "^1.5.0",
794
+ "@jridgewell/trace-mapping": "^0.3.24"
795
+ }
796
+ },
797
+ "node_modules/@jridgewell/remapping": {
798
+ "version": "2.3.5",
799
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
800
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
801
+ "dev": true,
802
+ "license": "MIT",
803
+ "dependencies": {
804
+ "@jridgewell/gen-mapping": "^0.3.5",
805
+ "@jridgewell/trace-mapping": "^0.3.24"
806
+ }
807
+ },
808
+ "node_modules/@jridgewell/resolve-uri": {
809
+ "version": "3.1.2",
810
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
811
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
812
+ "dev": true,
813
+ "license": "MIT",
814
+ "engines": {
815
+ "node": ">=6.0.0"
816
+ }
817
+ },
818
+ "node_modules/@jridgewell/sourcemap-codec": {
819
+ "version": "1.5.5",
820
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
821
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
822
+ "dev": true,
823
+ "license": "MIT"
824
+ },
825
+ "node_modules/@jridgewell/trace-mapping": {
826
+ "version": "0.3.31",
827
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
828
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
829
+ "dev": true,
830
+ "license": "MIT",
831
+ "dependencies": {
832
+ "@jridgewell/resolve-uri": "^3.1.0",
833
+ "@jridgewell/sourcemap-codec": "^1.4.14"
834
+ }
835
+ },
836
+ "node_modules/@rolldown/pluginutils": {
837
+ "version": "1.0.0-rc.3",
838
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
839
+ "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==",
840
+ "dev": true,
841
+ "license": "MIT"
842
+ },
843
+ "node_modules/@rollup/rollup-android-arm-eabi": {
844
+ "version": "4.59.0",
845
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
846
+ "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
847
+ "cpu": [
848
+ "arm"
849
+ ],
850
+ "dev": true,
851
+ "license": "MIT",
852
+ "optional": true,
853
+ "os": [
854
+ "android"
855
+ ]
856
+ },
857
+ "node_modules/@rollup/rollup-android-arm64": {
858
+ "version": "4.59.0",
859
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
860
+ "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
861
+ "cpu": [
862
+ "arm64"
863
+ ],
864
+ "dev": true,
865
+ "license": "MIT",
866
+ "optional": true,
867
+ "os": [
868
+ "android"
869
+ ]
870
+ },
871
+ "node_modules/@rollup/rollup-darwin-arm64": {
872
+ "version": "4.59.0",
873
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
874
+ "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
875
+ "cpu": [
876
+ "arm64"
877
+ ],
878
+ "dev": true,
879
+ "license": "MIT",
880
+ "optional": true,
881
+ "os": [
882
+ "darwin"
883
+ ]
884
+ },
885
+ "node_modules/@rollup/rollup-darwin-x64": {
886
+ "version": "4.59.0",
887
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
888
+ "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
889
+ "cpu": [
890
+ "x64"
891
+ ],
892
+ "dev": true,
893
+ "license": "MIT",
894
+ "optional": true,
895
+ "os": [
896
+ "darwin"
897
+ ]
898
+ },
899
+ "node_modules/@rollup/rollup-freebsd-arm64": {
900
+ "version": "4.59.0",
901
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
902
+ "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
903
+ "cpu": [
904
+ "arm64"
905
+ ],
906
+ "dev": true,
907
+ "license": "MIT",
908
+ "optional": true,
909
+ "os": [
910
+ "freebsd"
911
+ ]
912
+ },
913
+ "node_modules/@rollup/rollup-freebsd-x64": {
914
+ "version": "4.59.0",
915
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
916
+ "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
917
+ "cpu": [
918
+ "x64"
919
+ ],
920
+ "dev": true,
921
+ "license": "MIT",
922
+ "optional": true,
923
+ "os": [
924
+ "freebsd"
925
+ ]
926
+ },
927
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
928
+ "version": "4.59.0",
929
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
930
+ "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
931
+ "cpu": [
932
+ "arm"
933
+ ],
934
+ "dev": true,
935
+ "license": "MIT",
936
+ "optional": true,
937
+ "os": [
938
+ "linux"
939
+ ]
940
+ },
941
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
942
+ "version": "4.59.0",
943
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
944
+ "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
945
+ "cpu": [
946
+ "arm"
947
+ ],
948
+ "dev": true,
949
+ "license": "MIT",
950
+ "optional": true,
951
+ "os": [
952
+ "linux"
953
+ ]
954
+ },
955
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
956
+ "version": "4.59.0",
957
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
958
+ "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
959
+ "cpu": [
960
+ "arm64"
961
+ ],
962
+ "dev": true,
963
+ "license": "MIT",
964
+ "optional": true,
965
+ "os": [
966
+ "linux"
967
+ ]
968
+ },
969
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
970
+ "version": "4.59.0",
971
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
972
+ "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
973
+ "cpu": [
974
+ "arm64"
975
+ ],
976
+ "dev": true,
977
+ "license": "MIT",
978
+ "optional": true,
979
+ "os": [
980
+ "linux"
981
+ ]
982
+ },
983
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
984
+ "version": "4.59.0",
985
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
986
+ "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
987
+ "cpu": [
988
+ "loong64"
989
+ ],
990
+ "dev": true,
991
+ "license": "MIT",
992
+ "optional": true,
993
+ "os": [
994
+ "linux"
995
+ ]
996
+ },
997
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
998
+ "version": "4.59.0",
999
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
1000
+ "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
1001
+ "cpu": [
1002
+ "loong64"
1003
+ ],
1004
+ "dev": true,
1005
+ "license": "MIT",
1006
+ "optional": true,
1007
+ "os": [
1008
+ "linux"
1009
+ ]
1010
+ },
1011
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
1012
+ "version": "4.59.0",
1013
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
1014
+ "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
1015
+ "cpu": [
1016
+ "ppc64"
1017
+ ],
1018
+ "dev": true,
1019
+ "license": "MIT",
1020
+ "optional": true,
1021
+ "os": [
1022
+ "linux"
1023
+ ]
1024
+ },
1025
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
1026
+ "version": "4.59.0",
1027
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
1028
+ "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
1029
+ "cpu": [
1030
+ "ppc64"
1031
+ ],
1032
+ "dev": true,
1033
+ "license": "MIT",
1034
+ "optional": true,
1035
+ "os": [
1036
+ "linux"
1037
+ ]
1038
+ },
1039
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
1040
+ "version": "4.59.0",
1041
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
1042
+ "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
1043
+ "cpu": [
1044
+ "riscv64"
1045
+ ],
1046
+ "dev": true,
1047
+ "license": "MIT",
1048
+ "optional": true,
1049
+ "os": [
1050
+ "linux"
1051
+ ]
1052
+ },
1053
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
1054
+ "version": "4.59.0",
1055
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
1056
+ "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
1057
+ "cpu": [
1058
+ "riscv64"
1059
+ ],
1060
+ "dev": true,
1061
+ "license": "MIT",
1062
+ "optional": true,
1063
+ "os": [
1064
+ "linux"
1065
+ ]
1066
+ },
1067
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
1068
+ "version": "4.59.0",
1069
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
1070
+ "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
1071
+ "cpu": [
1072
+ "s390x"
1073
+ ],
1074
+ "dev": true,
1075
+ "license": "MIT",
1076
+ "optional": true,
1077
+ "os": [
1078
+ "linux"
1079
+ ]
1080
+ },
1081
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
1082
+ "version": "4.59.0",
1083
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
1084
+ "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
1085
+ "cpu": [
1086
+ "x64"
1087
+ ],
1088
+ "dev": true,
1089
+ "license": "MIT",
1090
+ "optional": true,
1091
+ "os": [
1092
+ "linux"
1093
+ ]
1094
+ },
1095
+ "node_modules/@rollup/rollup-linux-x64-musl": {
1096
+ "version": "4.59.0",
1097
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
1098
+ "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
1099
+ "cpu": [
1100
+ "x64"
1101
+ ],
1102
+ "dev": true,
1103
+ "license": "MIT",
1104
+ "optional": true,
1105
+ "os": [
1106
+ "linux"
1107
+ ]
1108
+ },
1109
+ "node_modules/@rollup/rollup-openbsd-x64": {
1110
+ "version": "4.59.0",
1111
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
1112
+ "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
1113
+ "cpu": [
1114
+ "x64"
1115
+ ],
1116
+ "dev": true,
1117
+ "license": "MIT",
1118
+ "optional": true,
1119
+ "os": [
1120
+ "openbsd"
1121
+ ]
1122
+ },
1123
+ "node_modules/@rollup/rollup-openharmony-arm64": {
1124
+ "version": "4.59.0",
1125
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
1126
+ "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
1127
+ "cpu": [
1128
+ "arm64"
1129
+ ],
1130
+ "dev": true,
1131
+ "license": "MIT",
1132
+ "optional": true,
1133
+ "os": [
1134
+ "openharmony"
1135
+ ]
1136
+ },
1137
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
1138
+ "version": "4.59.0",
1139
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
1140
+ "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
1141
+ "cpu": [
1142
+ "arm64"
1143
+ ],
1144
+ "dev": true,
1145
+ "license": "MIT",
1146
+ "optional": true,
1147
+ "os": [
1148
+ "win32"
1149
+ ]
1150
+ },
1151
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
1152
+ "version": "4.59.0",
1153
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
1154
+ "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
1155
+ "cpu": [
1156
+ "ia32"
1157
+ ],
1158
+ "dev": true,
1159
+ "license": "MIT",
1160
+ "optional": true,
1161
+ "os": [
1162
+ "win32"
1163
+ ]
1164
+ },
1165
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
1166
+ "version": "4.59.0",
1167
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
1168
+ "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
1169
+ "cpu": [
1170
+ "x64"
1171
+ ],
1172
+ "dev": true,
1173
+ "license": "MIT",
1174
+ "optional": true,
1175
+ "os": [
1176
+ "win32"
1177
+ ]
1178
+ },
1179
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
1180
+ "version": "4.59.0",
1181
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
1182
+ "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
1183
+ "cpu": [
1184
+ "x64"
1185
+ ],
1186
+ "dev": true,
1187
+ "license": "MIT",
1188
+ "optional": true,
1189
+ "os": [
1190
+ "win32"
1191
+ ]
1192
+ },
1193
+ "node_modules/@standard-schema/spec": {
1194
+ "version": "1.1.0",
1195
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
1196
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
1197
+ "dev": true,
1198
+ "license": "MIT"
1199
+ },
1200
+ "node_modules/@tailwindcss/node": {
1201
+ "version": "4.2.1",
1202
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
1203
+ "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==",
1204
+ "dev": true,
1205
+ "license": "MIT",
1206
+ "dependencies": {
1207
+ "@jridgewell/remapping": "^2.3.5",
1208
+ "enhanced-resolve": "^5.19.0",
1209
+ "jiti": "^2.6.1",
1210
+ "lightningcss": "1.31.1",
1211
+ "magic-string": "^0.30.21",
1212
+ "source-map-js": "^1.2.1",
1213
+ "tailwindcss": "4.2.1"
1214
+ }
1215
+ },
1216
+ "node_modules/@tailwindcss/oxide": {
1217
+ "version": "4.2.1",
1218
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz",
1219
+ "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==",
1220
+ "dev": true,
1221
+ "license": "MIT",
1222
+ "engines": {
1223
+ "node": ">= 20"
1224
+ },
1225
+ "optionalDependencies": {
1226
+ "@tailwindcss/oxide-android-arm64": "4.2.1",
1227
+ "@tailwindcss/oxide-darwin-arm64": "4.2.1",
1228
+ "@tailwindcss/oxide-darwin-x64": "4.2.1",
1229
+ "@tailwindcss/oxide-freebsd-x64": "4.2.1",
1230
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1",
1231
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1",
1232
+ "@tailwindcss/oxide-linux-arm64-musl": "4.2.1",
1233
+ "@tailwindcss/oxide-linux-x64-gnu": "4.2.1",
1234
+ "@tailwindcss/oxide-linux-x64-musl": "4.2.1",
1235
+ "@tailwindcss/oxide-wasm32-wasi": "4.2.1",
1236
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1",
1237
+ "@tailwindcss/oxide-win32-x64-msvc": "4.2.1"
1238
+ }
1239
+ },
1240
+ "node_modules/@tailwindcss/oxide-android-arm64": {
1241
+ "version": "4.2.1",
1242
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz",
1243
+ "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==",
1244
+ "cpu": [
1245
+ "arm64"
1246
+ ],
1247
+ "dev": true,
1248
+ "license": "MIT",
1249
+ "optional": true,
1250
+ "os": [
1251
+ "android"
1252
+ ],
1253
+ "engines": {
1254
+ "node": ">= 20"
1255
+ }
1256
+ },
1257
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
1258
+ "version": "4.2.1",
1259
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz",
1260
+ "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==",
1261
+ "cpu": [
1262
+ "arm64"
1263
+ ],
1264
+ "dev": true,
1265
+ "license": "MIT",
1266
+ "optional": true,
1267
+ "os": [
1268
+ "darwin"
1269
+ ],
1270
+ "engines": {
1271
+ "node": ">= 20"
1272
+ }
1273
+ },
1274
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
1275
+ "version": "4.2.1",
1276
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz",
1277
+ "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==",
1278
+ "cpu": [
1279
+ "x64"
1280
+ ],
1281
+ "dev": true,
1282
+ "license": "MIT",
1283
+ "optional": true,
1284
+ "os": [
1285
+ "darwin"
1286
+ ],
1287
+ "engines": {
1288
+ "node": ">= 20"
1289
+ }
1290
+ },
1291
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
1292
+ "version": "4.2.1",
1293
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz",
1294
+ "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==",
1295
+ "cpu": [
1296
+ "x64"
1297
+ ],
1298
+ "dev": true,
1299
+ "license": "MIT",
1300
+ "optional": true,
1301
+ "os": [
1302
+ "freebsd"
1303
+ ],
1304
+ "engines": {
1305
+ "node": ">= 20"
1306
+ }
1307
+ },
1308
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
1309
+ "version": "4.2.1",
1310
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz",
1311
+ "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==",
1312
+ "cpu": [
1313
+ "arm"
1314
+ ],
1315
+ "dev": true,
1316
+ "license": "MIT",
1317
+ "optional": true,
1318
+ "os": [
1319
+ "linux"
1320
+ ],
1321
+ "engines": {
1322
+ "node": ">= 20"
1323
+ }
1324
+ },
1325
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
1326
+ "version": "4.2.1",
1327
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz",
1328
+ "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==",
1329
+ "cpu": [
1330
+ "arm64"
1331
+ ],
1332
+ "dev": true,
1333
+ "license": "MIT",
1334
+ "optional": true,
1335
+ "os": [
1336
+ "linux"
1337
+ ],
1338
+ "engines": {
1339
+ "node": ">= 20"
1340
+ }
1341
+ },
1342
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
1343
+ "version": "4.2.1",
1344
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz",
1345
+ "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==",
1346
+ "cpu": [
1347
+ "arm64"
1348
+ ],
1349
+ "dev": true,
1350
+ "license": "MIT",
1351
+ "optional": true,
1352
+ "os": [
1353
+ "linux"
1354
+ ],
1355
+ "engines": {
1356
+ "node": ">= 20"
1357
+ }
1358
+ },
1359
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
1360
+ "version": "4.2.1",
1361
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz",
1362
+ "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==",
1363
+ "cpu": [
1364
+ "x64"
1365
+ ],
1366
+ "dev": true,
1367
+ "license": "MIT",
1368
+ "optional": true,
1369
+ "os": [
1370
+ "linux"
1371
+ ],
1372
+ "engines": {
1373
+ "node": ">= 20"
1374
+ }
1375
+ },
1376
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
1377
+ "version": "4.2.1",
1378
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz",
1379
+ "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==",
1380
+ "cpu": [
1381
+ "x64"
1382
+ ],
1383
+ "dev": true,
1384
+ "license": "MIT",
1385
+ "optional": true,
1386
+ "os": [
1387
+ "linux"
1388
+ ],
1389
+ "engines": {
1390
+ "node": ">= 20"
1391
+ }
1392
+ },
1393
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
1394
+ "version": "4.2.1",
1395
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz",
1396
+ "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==",
1397
+ "bundleDependencies": [
1398
+ "@napi-rs/wasm-runtime",
1399
+ "@emnapi/core",
1400
+ "@emnapi/runtime",
1401
+ "@tybys/wasm-util",
1402
+ "@emnapi/wasi-threads",
1403
+ "tslib"
1404
+ ],
1405
+ "cpu": [
1406
+ "wasm32"
1407
+ ],
1408
+ "dev": true,
1409
+ "license": "MIT",
1410
+ "optional": true,
1411
+ "dependencies": {
1412
+ "@emnapi/core": "^1.8.1",
1413
+ "@emnapi/runtime": "^1.8.1",
1414
+ "@emnapi/wasi-threads": "^1.1.0",
1415
+ "@napi-rs/wasm-runtime": "^1.1.1",
1416
+ "@tybys/wasm-util": "^0.10.1",
1417
+ "tslib": "^2.8.1"
1418
+ },
1419
+ "engines": {
1420
+ "node": ">=14.0.0"
1421
+ }
1422
+ },
1423
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
1424
+ "version": "4.2.1",
1425
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
1426
+ "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==",
1427
+ "cpu": [
1428
+ "arm64"
1429
+ ],
1430
+ "dev": true,
1431
+ "license": "MIT",
1432
+ "optional": true,
1433
+ "os": [
1434
+ "win32"
1435
+ ],
1436
+ "engines": {
1437
+ "node": ">= 20"
1438
+ }
1439
+ },
1440
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
1441
+ "version": "4.2.1",
1442
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz",
1443
+ "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==",
1444
+ "cpu": [
1445
+ "x64"
1446
+ ],
1447
+ "dev": true,
1448
+ "license": "MIT",
1449
+ "optional": true,
1450
+ "os": [
1451
+ "win32"
1452
+ ],
1453
+ "engines": {
1454
+ "node": ">= 20"
1455
+ }
1456
+ },
1457
+ "node_modules/@tailwindcss/vite": {
1458
+ "version": "4.2.1",
1459
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz",
1460
+ "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==",
1461
+ "dev": true,
1462
+ "license": "MIT",
1463
+ "dependencies": {
1464
+ "@tailwindcss/node": "4.2.1",
1465
+ "@tailwindcss/oxide": "4.2.1",
1466
+ "tailwindcss": "4.2.1"
1467
+ },
1468
+ "peerDependencies": {
1469
+ "vite": "^5.2.0 || ^6 || ^7"
1470
+ }
1471
+ },
1472
+ "node_modules/@types/babel__core": {
1473
+ "version": "7.20.5",
1474
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
1475
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
1476
+ "dev": true,
1477
+ "license": "MIT",
1478
+ "dependencies": {
1479
+ "@babel/parser": "^7.20.7",
1480
+ "@babel/types": "^7.20.7",
1481
+ "@types/babel__generator": "*",
1482
+ "@types/babel__template": "*",
1483
+ "@types/babel__traverse": "*"
1484
+ }
1485
+ },
1486
+ "node_modules/@types/babel__generator": {
1487
+ "version": "7.27.0",
1488
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
1489
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
1490
+ "dev": true,
1491
+ "license": "MIT",
1492
+ "dependencies": {
1493
+ "@babel/types": "^7.0.0"
1494
+ }
1495
+ },
1496
+ "node_modules/@types/babel__template": {
1497
+ "version": "7.4.4",
1498
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
1499
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
1500
+ "dev": true,
1501
+ "license": "MIT",
1502
+ "dependencies": {
1503
+ "@babel/parser": "^7.1.0",
1504
+ "@babel/types": "^7.0.0"
1505
+ }
1506
+ },
1507
+ "node_modules/@types/babel__traverse": {
1508
+ "version": "7.28.0",
1509
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1510
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1511
+ "dev": true,
1512
+ "license": "MIT",
1513
+ "dependencies": {
1514
+ "@babel/types": "^7.28.2"
1515
+ }
1516
+ },
1517
+ "node_modules/@types/chai": {
1518
+ "version": "5.2.3",
1519
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
1520
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
1521
+ "dev": true,
1522
+ "license": "MIT",
1523
+ "dependencies": {
1524
+ "@types/deep-eql": "*",
1525
+ "assertion-error": "^2.0.1"
1526
+ }
1527
+ },
1528
+ "node_modules/@types/deep-eql": {
1529
+ "version": "4.0.2",
1530
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
1531
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
1532
+ "dev": true,
1533
+ "license": "MIT"
1534
+ },
1535
+ "node_modules/@types/estree": {
1536
+ "version": "1.0.8",
1537
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
1538
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
1539
+ "dev": true,
1540
+ "license": "MIT"
1541
+ },
1542
+ "node_modules/@types/node": {
1543
+ "version": "25.3.0",
1544
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz",
1545
+ "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==",
1546
+ "dev": true,
1547
+ "license": "MIT",
1548
+ "dependencies": {
1549
+ "undici-types": "~7.18.0"
1550
+ }
1551
+ },
1552
+ "node_modules/@types/react": {
1553
+ "version": "19.2.14",
1554
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
1555
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
1556
+ "dev": true,
1557
+ "license": "MIT",
1558
+ "dependencies": {
1559
+ "csstype": "^3.2.2"
1560
+ }
1561
+ },
1562
+ "node_modules/@types/react-dom": {
1563
+ "version": "19.2.3",
1564
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
1565
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
1566
+ "dev": true,
1567
+ "license": "MIT",
1568
+ "peerDependencies": {
1569
+ "@types/react": "^19.2.0"
1570
+ }
1571
+ },
1572
+ "node_modules/@vitejs/plugin-react": {
1573
+ "version": "5.1.4",
1574
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
1575
+ "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==",
1576
+ "dev": true,
1577
+ "license": "MIT",
1578
+ "dependencies": {
1579
+ "@babel/core": "^7.29.0",
1580
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
1581
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
1582
+ "@rolldown/pluginutils": "1.0.0-rc.3",
1583
+ "@types/babel__core": "^7.20.5",
1584
+ "react-refresh": "^0.18.0"
1585
+ },
1586
+ "engines": {
1587
+ "node": "^20.19.0 || >=22.12.0"
1588
+ },
1589
+ "peerDependencies": {
1590
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1591
+ }
1592
+ },
1593
+ "node_modules/@vitest/expect": {
1594
+ "version": "4.0.18",
1595
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
1596
+ "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
1597
+ "dev": true,
1598
+ "license": "MIT",
1599
+ "dependencies": {
1600
+ "@standard-schema/spec": "^1.0.0",
1601
+ "@types/chai": "^5.2.2",
1602
+ "@vitest/spy": "4.0.18",
1603
+ "@vitest/utils": "4.0.18",
1604
+ "chai": "^6.2.1",
1605
+ "tinyrainbow": "^3.0.3"
1606
+ },
1607
+ "funding": {
1608
+ "url": "https://opencollective.com/vitest"
1609
+ }
1610
+ },
1611
+ "node_modules/@vitest/mocker": {
1612
+ "version": "4.0.18",
1613
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
1614
+ "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
1615
+ "dev": true,
1616
+ "license": "MIT",
1617
+ "dependencies": {
1618
+ "@vitest/spy": "4.0.18",
1619
+ "estree-walker": "^3.0.3",
1620
+ "magic-string": "^0.30.21"
1621
+ },
1622
+ "funding": {
1623
+ "url": "https://opencollective.com/vitest"
1624
+ },
1625
+ "peerDependencies": {
1626
+ "msw": "^2.4.9",
1627
+ "vite": "^6.0.0 || ^7.0.0-0"
1628
+ },
1629
+ "peerDependenciesMeta": {
1630
+ "msw": {
1631
+ "optional": true
1632
+ },
1633
+ "vite": {
1634
+ "optional": true
1635
+ }
1636
+ }
1637
+ },
1638
+ "node_modules/@vitest/pretty-format": {
1639
+ "version": "4.0.18",
1640
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
1641
+ "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
1642
+ "dev": true,
1643
+ "license": "MIT",
1644
+ "dependencies": {
1645
+ "tinyrainbow": "^3.0.3"
1646
+ },
1647
+ "funding": {
1648
+ "url": "https://opencollective.com/vitest"
1649
+ }
1650
+ },
1651
+ "node_modules/@vitest/runner": {
1652
+ "version": "4.0.18",
1653
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
1654
+ "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
1655
+ "dev": true,
1656
+ "license": "MIT",
1657
+ "dependencies": {
1658
+ "@vitest/utils": "4.0.18",
1659
+ "pathe": "^2.0.3"
1660
+ },
1661
+ "funding": {
1662
+ "url": "https://opencollective.com/vitest"
1663
+ }
1664
+ },
1665
+ "node_modules/@vitest/snapshot": {
1666
+ "version": "4.0.18",
1667
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
1668
+ "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
1669
+ "dev": true,
1670
+ "license": "MIT",
1671
+ "dependencies": {
1672
+ "@vitest/pretty-format": "4.0.18",
1673
+ "magic-string": "^0.30.21",
1674
+ "pathe": "^2.0.3"
1675
+ },
1676
+ "funding": {
1677
+ "url": "https://opencollective.com/vitest"
1678
+ }
1679
+ },
1680
+ "node_modules/@vitest/spy": {
1681
+ "version": "4.0.18",
1682
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
1683
+ "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
1684
+ "dev": true,
1685
+ "license": "MIT",
1686
+ "funding": {
1687
+ "url": "https://opencollective.com/vitest"
1688
+ }
1689
+ },
1690
+ "node_modules/@vitest/utils": {
1691
+ "version": "4.0.18",
1692
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
1693
+ "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
1694
+ "dev": true,
1695
+ "license": "MIT",
1696
+ "dependencies": {
1697
+ "@vitest/pretty-format": "4.0.18",
1698
+ "tinyrainbow": "^3.0.3"
1699
+ },
1700
+ "funding": {
1701
+ "url": "https://opencollective.com/vitest"
1702
+ }
1703
+ },
1704
+ "node_modules/assertion-error": {
1705
+ "version": "2.0.1",
1706
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
1707
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
1708
+ "dev": true,
1709
+ "license": "MIT",
1710
+ "engines": {
1711
+ "node": ">=12"
1712
+ }
1713
+ },
1714
+ "node_modules/baseline-browser-mapping": {
1715
+ "version": "2.10.0",
1716
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
1717
+ "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
1718
+ "dev": true,
1719
+ "license": "Apache-2.0",
1720
+ "bin": {
1721
+ "baseline-browser-mapping": "dist/cli.cjs"
1722
+ },
1723
+ "engines": {
1724
+ "node": ">=6.0.0"
1725
+ }
1726
+ },
1727
+ "node_modules/browserslist": {
1728
+ "version": "4.28.1",
1729
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
1730
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
1731
+ "dev": true,
1732
+ "funding": [
1733
+ {
1734
+ "type": "opencollective",
1735
+ "url": "https://opencollective.com/browserslist"
1736
+ },
1737
+ {
1738
+ "type": "tidelift",
1739
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1740
+ },
1741
+ {
1742
+ "type": "github",
1743
+ "url": "https://github.com/sponsors/ai"
1744
+ }
1745
+ ],
1746
+ "license": "MIT",
1747
+ "dependencies": {
1748
+ "baseline-browser-mapping": "^2.9.0",
1749
+ "caniuse-lite": "^1.0.30001759",
1750
+ "electron-to-chromium": "^1.5.263",
1751
+ "node-releases": "^2.0.27",
1752
+ "update-browserslist-db": "^1.2.0"
1753
+ },
1754
+ "bin": {
1755
+ "browserslist": "cli.js"
1756
+ },
1757
+ "engines": {
1758
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1759
+ }
1760
+ },
1761
+ "node_modules/caniuse-lite": {
1762
+ "version": "1.0.30001774",
1763
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
1764
+ "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
1765
+ "dev": true,
1766
+ "funding": [
1767
+ {
1768
+ "type": "opencollective",
1769
+ "url": "https://opencollective.com/browserslist"
1770
+ },
1771
+ {
1772
+ "type": "tidelift",
1773
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1774
+ },
1775
+ {
1776
+ "type": "github",
1777
+ "url": "https://github.com/sponsors/ai"
1778
+ }
1779
+ ],
1780
+ "license": "CC-BY-4.0"
1781
+ },
1782
+ "node_modules/chai": {
1783
+ "version": "6.2.2",
1784
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
1785
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
1786
+ "dev": true,
1787
+ "license": "MIT",
1788
+ "engines": {
1789
+ "node": ">=18"
1790
+ }
1791
+ },
1792
+ "node_modules/convert-source-map": {
1793
+ "version": "2.0.0",
1794
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1795
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1796
+ "dev": true,
1797
+ "license": "MIT"
1798
+ },
1799
+ "node_modules/csstype": {
1800
+ "version": "3.2.3",
1801
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1802
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1803
+ "dev": true,
1804
+ "license": "MIT"
1805
+ },
1806
+ "node_modules/debug": {
1807
+ "version": "4.4.3",
1808
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1809
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1810
+ "dev": true,
1811
+ "license": "MIT",
1812
+ "dependencies": {
1813
+ "ms": "^2.1.3"
1814
+ },
1815
+ "engines": {
1816
+ "node": ">=6.0"
1817
+ },
1818
+ "peerDependenciesMeta": {
1819
+ "supports-color": {
1820
+ "optional": true
1821
+ }
1822
+ }
1823
+ },
1824
+ "node_modules/detect-libc": {
1825
+ "version": "2.1.2",
1826
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
1827
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
1828
+ "dev": true,
1829
+ "license": "Apache-2.0",
1830
+ "engines": {
1831
+ "node": ">=8"
1832
+ }
1833
+ },
1834
+ "node_modules/electron-to-chromium": {
1835
+ "version": "1.5.302",
1836
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
1837
+ "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
1838
+ "dev": true,
1839
+ "license": "ISC"
1840
+ },
1841
+ "node_modules/enhanced-resolve": {
1842
+ "version": "5.19.0",
1843
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
1844
+ "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
1845
+ "dev": true,
1846
+ "license": "MIT",
1847
+ "dependencies": {
1848
+ "graceful-fs": "^4.2.4",
1849
+ "tapable": "^2.3.0"
1850
+ },
1851
+ "engines": {
1852
+ "node": ">=10.13.0"
1853
+ }
1854
+ },
1855
+ "node_modules/es-module-lexer": {
1856
+ "version": "1.7.0",
1857
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
1858
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
1859
+ "dev": true,
1860
+ "license": "MIT"
1861
+ },
1862
+ "node_modules/esbuild": {
1863
+ "version": "0.27.3",
1864
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
1865
+ "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
1866
+ "dev": true,
1867
+ "hasInstallScript": true,
1868
+ "license": "MIT",
1869
+ "bin": {
1870
+ "esbuild": "bin/esbuild"
1871
+ },
1872
+ "engines": {
1873
+ "node": ">=18"
1874
+ },
1875
+ "optionalDependencies": {
1876
+ "@esbuild/aix-ppc64": "0.27.3",
1877
+ "@esbuild/android-arm": "0.27.3",
1878
+ "@esbuild/android-arm64": "0.27.3",
1879
+ "@esbuild/android-x64": "0.27.3",
1880
+ "@esbuild/darwin-arm64": "0.27.3",
1881
+ "@esbuild/darwin-x64": "0.27.3",
1882
+ "@esbuild/freebsd-arm64": "0.27.3",
1883
+ "@esbuild/freebsd-x64": "0.27.3",
1884
+ "@esbuild/linux-arm": "0.27.3",
1885
+ "@esbuild/linux-arm64": "0.27.3",
1886
+ "@esbuild/linux-ia32": "0.27.3",
1887
+ "@esbuild/linux-loong64": "0.27.3",
1888
+ "@esbuild/linux-mips64el": "0.27.3",
1889
+ "@esbuild/linux-ppc64": "0.27.3",
1890
+ "@esbuild/linux-riscv64": "0.27.3",
1891
+ "@esbuild/linux-s390x": "0.27.3",
1892
+ "@esbuild/linux-x64": "0.27.3",
1893
+ "@esbuild/netbsd-arm64": "0.27.3",
1894
+ "@esbuild/netbsd-x64": "0.27.3",
1895
+ "@esbuild/openbsd-arm64": "0.27.3",
1896
+ "@esbuild/openbsd-x64": "0.27.3",
1897
+ "@esbuild/openharmony-arm64": "0.27.3",
1898
+ "@esbuild/sunos-x64": "0.27.3",
1899
+ "@esbuild/win32-arm64": "0.27.3",
1900
+ "@esbuild/win32-ia32": "0.27.3",
1901
+ "@esbuild/win32-x64": "0.27.3"
1902
+ }
1903
+ },
1904
+ "node_modules/escalade": {
1905
+ "version": "3.2.0",
1906
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1907
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1908
+ "dev": true,
1909
+ "license": "MIT",
1910
+ "engines": {
1911
+ "node": ">=6"
1912
+ }
1913
+ },
1914
+ "node_modules/estree-walker": {
1915
+ "version": "3.0.3",
1916
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
1917
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
1918
+ "dev": true,
1919
+ "license": "MIT",
1920
+ "dependencies": {
1921
+ "@types/estree": "^1.0.0"
1922
+ }
1923
+ },
1924
+ "node_modules/expect-type": {
1925
+ "version": "1.3.0",
1926
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
1927
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
1928
+ "dev": true,
1929
+ "license": "Apache-2.0",
1930
+ "engines": {
1931
+ "node": ">=12.0.0"
1932
+ }
1933
+ },
1934
+ "node_modules/fdir": {
1935
+ "version": "6.5.0",
1936
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
1937
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
1938
+ "dev": true,
1939
+ "license": "MIT",
1940
+ "engines": {
1941
+ "node": ">=12.0.0"
1942
+ },
1943
+ "peerDependencies": {
1944
+ "picomatch": "^3 || ^4"
1945
+ },
1946
+ "peerDependenciesMeta": {
1947
+ "picomatch": {
1948
+ "optional": true
1949
+ }
1950
+ }
1951
+ },
1952
+ "node_modules/fsevents": {
1953
+ "version": "2.3.3",
1954
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1955
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1956
+ "dev": true,
1957
+ "hasInstallScript": true,
1958
+ "license": "MIT",
1959
+ "optional": true,
1960
+ "os": [
1961
+ "darwin"
1962
+ ],
1963
+ "engines": {
1964
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1965
+ }
1966
+ },
1967
+ "node_modules/gensync": {
1968
+ "version": "1.0.0-beta.2",
1969
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1970
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1971
+ "dev": true,
1972
+ "license": "MIT",
1973
+ "engines": {
1974
+ "node": ">=6.9.0"
1975
+ }
1976
+ },
1977
+ "node_modules/graceful-fs": {
1978
+ "version": "4.2.11",
1979
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
1980
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
1981
+ "dev": true,
1982
+ "license": "ISC"
1983
+ },
1984
+ "node_modules/jiti": {
1985
+ "version": "2.6.1",
1986
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
1987
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
1988
+ "dev": true,
1989
+ "license": "MIT",
1990
+ "bin": {
1991
+ "jiti": "lib/jiti-cli.mjs"
1992
+ }
1993
+ },
1994
+ "node_modules/js-tokens": {
1995
+ "version": "4.0.0",
1996
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1997
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1998
+ "dev": true,
1999
+ "license": "MIT"
2000
+ },
2001
+ "node_modules/jsesc": {
2002
+ "version": "3.1.0",
2003
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
2004
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
2005
+ "dev": true,
2006
+ "license": "MIT",
2007
+ "bin": {
2008
+ "jsesc": "bin/jsesc"
2009
+ },
2010
+ "engines": {
2011
+ "node": ">=6"
2012
+ }
2013
+ },
2014
+ "node_modules/json5": {
2015
+ "version": "2.2.3",
2016
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
2017
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
2018
+ "dev": true,
2019
+ "license": "MIT",
2020
+ "bin": {
2021
+ "json5": "lib/cli.js"
2022
+ },
2023
+ "engines": {
2024
+ "node": ">=6"
2025
+ }
2026
+ },
2027
+ "node_modules/lightningcss": {
2028
+ "version": "1.31.1",
2029
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
2030
+ "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
2031
+ "dev": true,
2032
+ "license": "MPL-2.0",
2033
+ "dependencies": {
2034
+ "detect-libc": "^2.0.3"
2035
+ },
2036
+ "engines": {
2037
+ "node": ">= 12.0.0"
2038
+ },
2039
+ "funding": {
2040
+ "type": "opencollective",
2041
+ "url": "https://opencollective.com/parcel"
2042
+ },
2043
+ "optionalDependencies": {
2044
+ "lightningcss-android-arm64": "1.31.1",
2045
+ "lightningcss-darwin-arm64": "1.31.1",
2046
+ "lightningcss-darwin-x64": "1.31.1",
2047
+ "lightningcss-freebsd-x64": "1.31.1",
2048
+ "lightningcss-linux-arm-gnueabihf": "1.31.1",
2049
+ "lightningcss-linux-arm64-gnu": "1.31.1",
2050
+ "lightningcss-linux-arm64-musl": "1.31.1",
2051
+ "lightningcss-linux-x64-gnu": "1.31.1",
2052
+ "lightningcss-linux-x64-musl": "1.31.1",
2053
+ "lightningcss-win32-arm64-msvc": "1.31.1",
2054
+ "lightningcss-win32-x64-msvc": "1.31.1"
2055
+ }
2056
+ },
2057
+ "node_modules/lightningcss-android-arm64": {
2058
+ "version": "1.31.1",
2059
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz",
2060
+ "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==",
2061
+ "cpu": [
2062
+ "arm64"
2063
+ ],
2064
+ "dev": true,
2065
+ "license": "MPL-2.0",
2066
+ "optional": true,
2067
+ "os": [
2068
+ "android"
2069
+ ],
2070
+ "engines": {
2071
+ "node": ">= 12.0.0"
2072
+ },
2073
+ "funding": {
2074
+ "type": "opencollective",
2075
+ "url": "https://opencollective.com/parcel"
2076
+ }
2077
+ },
2078
+ "node_modules/lightningcss-darwin-arm64": {
2079
+ "version": "1.31.1",
2080
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz",
2081
+ "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==",
2082
+ "cpu": [
2083
+ "arm64"
2084
+ ],
2085
+ "dev": true,
2086
+ "license": "MPL-2.0",
2087
+ "optional": true,
2088
+ "os": [
2089
+ "darwin"
2090
+ ],
2091
+ "engines": {
2092
+ "node": ">= 12.0.0"
2093
+ },
2094
+ "funding": {
2095
+ "type": "opencollective",
2096
+ "url": "https://opencollective.com/parcel"
2097
+ }
2098
+ },
2099
+ "node_modules/lightningcss-darwin-x64": {
2100
+ "version": "1.31.1",
2101
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz",
2102
+ "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==",
2103
+ "cpu": [
2104
+ "x64"
2105
+ ],
2106
+ "dev": true,
2107
+ "license": "MPL-2.0",
2108
+ "optional": true,
2109
+ "os": [
2110
+ "darwin"
2111
+ ],
2112
+ "engines": {
2113
+ "node": ">= 12.0.0"
2114
+ },
2115
+ "funding": {
2116
+ "type": "opencollective",
2117
+ "url": "https://opencollective.com/parcel"
2118
+ }
2119
+ },
2120
+ "node_modules/lightningcss-freebsd-x64": {
2121
+ "version": "1.31.1",
2122
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz",
2123
+ "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==",
2124
+ "cpu": [
2125
+ "x64"
2126
+ ],
2127
+ "dev": true,
2128
+ "license": "MPL-2.0",
2129
+ "optional": true,
2130
+ "os": [
2131
+ "freebsd"
2132
+ ],
2133
+ "engines": {
2134
+ "node": ">= 12.0.0"
2135
+ },
2136
+ "funding": {
2137
+ "type": "opencollective",
2138
+ "url": "https://opencollective.com/parcel"
2139
+ }
2140
+ },
2141
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
2142
+ "version": "1.31.1",
2143
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz",
2144
+ "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==",
2145
+ "cpu": [
2146
+ "arm"
2147
+ ],
2148
+ "dev": true,
2149
+ "license": "MPL-2.0",
2150
+ "optional": true,
2151
+ "os": [
2152
+ "linux"
2153
+ ],
2154
+ "engines": {
2155
+ "node": ">= 12.0.0"
2156
+ },
2157
+ "funding": {
2158
+ "type": "opencollective",
2159
+ "url": "https://opencollective.com/parcel"
2160
+ }
2161
+ },
2162
+ "node_modules/lightningcss-linux-arm64-gnu": {
2163
+ "version": "1.31.1",
2164
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz",
2165
+ "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==",
2166
+ "cpu": [
2167
+ "arm64"
2168
+ ],
2169
+ "dev": true,
2170
+ "license": "MPL-2.0",
2171
+ "optional": true,
2172
+ "os": [
2173
+ "linux"
2174
+ ],
2175
+ "engines": {
2176
+ "node": ">= 12.0.0"
2177
+ },
2178
+ "funding": {
2179
+ "type": "opencollective",
2180
+ "url": "https://opencollective.com/parcel"
2181
+ }
2182
+ },
2183
+ "node_modules/lightningcss-linux-arm64-musl": {
2184
+ "version": "1.31.1",
2185
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz",
2186
+ "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==",
2187
+ "cpu": [
2188
+ "arm64"
2189
+ ],
2190
+ "dev": true,
2191
+ "license": "MPL-2.0",
2192
+ "optional": true,
2193
+ "os": [
2194
+ "linux"
2195
+ ],
2196
+ "engines": {
2197
+ "node": ">= 12.0.0"
2198
+ },
2199
+ "funding": {
2200
+ "type": "opencollective",
2201
+ "url": "https://opencollective.com/parcel"
2202
+ }
2203
+ },
2204
+ "node_modules/lightningcss-linux-x64-gnu": {
2205
+ "version": "1.31.1",
2206
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
2207
+ "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==",
2208
+ "cpu": [
2209
+ "x64"
2210
+ ],
2211
+ "dev": true,
2212
+ "license": "MPL-2.0",
2213
+ "optional": true,
2214
+ "os": [
2215
+ "linux"
2216
+ ],
2217
+ "engines": {
2218
+ "node": ">= 12.0.0"
2219
+ },
2220
+ "funding": {
2221
+ "type": "opencollective",
2222
+ "url": "https://opencollective.com/parcel"
2223
+ }
2224
+ },
2225
+ "node_modules/lightningcss-linux-x64-musl": {
2226
+ "version": "1.31.1",
2227
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
2228
+ "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
2229
+ "cpu": [
2230
+ "x64"
2231
+ ],
2232
+ "dev": true,
2233
+ "license": "MPL-2.0",
2234
+ "optional": true,
2235
+ "os": [
2236
+ "linux"
2237
+ ],
2238
+ "engines": {
2239
+ "node": ">= 12.0.0"
2240
+ },
2241
+ "funding": {
2242
+ "type": "opencollective",
2243
+ "url": "https://opencollective.com/parcel"
2244
+ }
2245
+ },
2246
+ "node_modules/lightningcss-win32-arm64-msvc": {
2247
+ "version": "1.31.1",
2248
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz",
2249
+ "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==",
2250
+ "cpu": [
2251
+ "arm64"
2252
+ ],
2253
+ "dev": true,
2254
+ "license": "MPL-2.0",
2255
+ "optional": true,
2256
+ "os": [
2257
+ "win32"
2258
+ ],
2259
+ "engines": {
2260
+ "node": ">= 12.0.0"
2261
+ },
2262
+ "funding": {
2263
+ "type": "opencollective",
2264
+ "url": "https://opencollective.com/parcel"
2265
+ }
2266
+ },
2267
+ "node_modules/lightningcss-win32-x64-msvc": {
2268
+ "version": "1.31.1",
2269
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz",
2270
+ "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==",
2271
+ "cpu": [
2272
+ "x64"
2273
+ ],
2274
+ "dev": true,
2275
+ "license": "MPL-2.0",
2276
+ "optional": true,
2277
+ "os": [
2278
+ "win32"
2279
+ ],
2280
+ "engines": {
2281
+ "node": ">= 12.0.0"
2282
+ },
2283
+ "funding": {
2284
+ "type": "opencollective",
2285
+ "url": "https://opencollective.com/parcel"
2286
+ }
2287
+ },
2288
+ "node_modules/lru-cache": {
2289
+ "version": "5.1.1",
2290
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
2291
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
2292
+ "dev": true,
2293
+ "license": "ISC",
2294
+ "dependencies": {
2295
+ "yallist": "^3.0.2"
2296
+ }
2297
+ },
2298
+ "node_modules/magic-string": {
2299
+ "version": "0.30.21",
2300
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
2301
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
2302
+ "dev": true,
2303
+ "license": "MIT",
2304
+ "dependencies": {
2305
+ "@jridgewell/sourcemap-codec": "^1.5.5"
2306
+ }
2307
+ },
2308
+ "node_modules/ms": {
2309
+ "version": "2.1.3",
2310
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
2311
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
2312
+ "dev": true,
2313
+ "license": "MIT"
2314
+ },
2315
+ "node_modules/nanoid": {
2316
+ "version": "3.3.11",
2317
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
2318
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
2319
+ "dev": true,
2320
+ "funding": [
2321
+ {
2322
+ "type": "github",
2323
+ "url": "https://github.com/sponsors/ai"
2324
+ }
2325
+ ],
2326
+ "license": "MIT",
2327
+ "bin": {
2328
+ "nanoid": "bin/nanoid.cjs"
2329
+ },
2330
+ "engines": {
2331
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
2332
+ }
2333
+ },
2334
+ "node_modules/node-releases": {
2335
+ "version": "2.0.27",
2336
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
2337
+ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
2338
+ "dev": true,
2339
+ "license": "MIT"
2340
+ },
2341
+ "node_modules/obug": {
2342
+ "version": "2.1.1",
2343
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
2344
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
2345
+ "dev": true,
2346
+ "funding": [
2347
+ "https://github.com/sponsors/sxzz",
2348
+ "https://opencollective.com/debug"
2349
+ ],
2350
+ "license": "MIT"
2351
+ },
2352
+ "node_modules/pathe": {
2353
+ "version": "2.0.3",
2354
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
2355
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
2356
+ "dev": true,
2357
+ "license": "MIT"
2358
+ },
2359
+ "node_modules/picocolors": {
2360
+ "version": "1.1.1",
2361
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
2362
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
2363
+ "dev": true,
2364
+ "license": "ISC"
2365
+ },
2366
+ "node_modules/picomatch": {
2367
+ "version": "4.0.3",
2368
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
2369
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
2370
+ "dev": true,
2371
+ "license": "MIT",
2372
+ "engines": {
2373
+ "node": ">=12"
2374
+ },
2375
+ "funding": {
2376
+ "url": "https://github.com/sponsors/jonschlinkert"
2377
+ }
2378
+ },
2379
+ "node_modules/postcss": {
2380
+ "version": "8.5.6",
2381
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
2382
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
2383
+ "dev": true,
2384
+ "funding": [
2385
+ {
2386
+ "type": "opencollective",
2387
+ "url": "https://opencollective.com/postcss/"
2388
+ },
2389
+ {
2390
+ "type": "tidelift",
2391
+ "url": "https://tidelift.com/funding/github/npm/postcss"
2392
+ },
2393
+ {
2394
+ "type": "github",
2395
+ "url": "https://github.com/sponsors/ai"
2396
+ }
2397
+ ],
2398
+ "license": "MIT",
2399
+ "dependencies": {
2400
+ "nanoid": "^3.3.11",
2401
+ "picocolors": "^1.1.1",
2402
+ "source-map-js": "^1.2.1"
2403
+ },
2404
+ "engines": {
2405
+ "node": "^10 || ^12 || >=14"
2406
+ }
2407
+ },
2408
+ "node_modules/react": {
2409
+ "version": "19.2.4",
2410
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
2411
+ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
2412
+ "license": "MIT",
2413
+ "engines": {
2414
+ "node": ">=0.10.0"
2415
+ }
2416
+ },
2417
+ "node_modules/react-dom": {
2418
+ "version": "19.2.4",
2419
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
2420
+ "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
2421
+ "license": "MIT",
2422
+ "dependencies": {
2423
+ "scheduler": "^0.27.0"
2424
+ },
2425
+ "peerDependencies": {
2426
+ "react": "^19.2.4"
2427
+ }
2428
+ },
2429
+ "node_modules/react-refresh": {
2430
+ "version": "0.18.0",
2431
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
2432
+ "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
2433
+ "dev": true,
2434
+ "license": "MIT",
2435
+ "engines": {
2436
+ "node": ">=0.10.0"
2437
+ }
2438
+ },
2439
+ "node_modules/rollup": {
2440
+ "version": "4.59.0",
2441
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
2442
+ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
2443
+ "dev": true,
2444
+ "license": "MIT",
2445
+ "dependencies": {
2446
+ "@types/estree": "1.0.8"
2447
+ },
2448
+ "bin": {
2449
+ "rollup": "dist/bin/rollup"
2450
+ },
2451
+ "engines": {
2452
+ "node": ">=18.0.0",
2453
+ "npm": ">=8.0.0"
2454
+ },
2455
+ "optionalDependencies": {
2456
+ "@rollup/rollup-android-arm-eabi": "4.59.0",
2457
+ "@rollup/rollup-android-arm64": "4.59.0",
2458
+ "@rollup/rollup-darwin-arm64": "4.59.0",
2459
+ "@rollup/rollup-darwin-x64": "4.59.0",
2460
+ "@rollup/rollup-freebsd-arm64": "4.59.0",
2461
+ "@rollup/rollup-freebsd-x64": "4.59.0",
2462
+ "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
2463
+ "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
2464
+ "@rollup/rollup-linux-arm64-gnu": "4.59.0",
2465
+ "@rollup/rollup-linux-arm64-musl": "4.59.0",
2466
+ "@rollup/rollup-linux-loong64-gnu": "4.59.0",
2467
+ "@rollup/rollup-linux-loong64-musl": "4.59.0",
2468
+ "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
2469
+ "@rollup/rollup-linux-ppc64-musl": "4.59.0",
2470
+ "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
2471
+ "@rollup/rollup-linux-riscv64-musl": "4.59.0",
2472
+ "@rollup/rollup-linux-s390x-gnu": "4.59.0",
2473
+ "@rollup/rollup-linux-x64-gnu": "4.59.0",
2474
+ "@rollup/rollup-linux-x64-musl": "4.59.0",
2475
+ "@rollup/rollup-openbsd-x64": "4.59.0",
2476
+ "@rollup/rollup-openharmony-arm64": "4.59.0",
2477
+ "@rollup/rollup-win32-arm64-msvc": "4.59.0",
2478
+ "@rollup/rollup-win32-ia32-msvc": "4.59.0",
2479
+ "@rollup/rollup-win32-x64-gnu": "4.59.0",
2480
+ "@rollup/rollup-win32-x64-msvc": "4.59.0",
2481
+ "fsevents": "~2.3.2"
2482
+ }
2483
+ },
2484
+ "node_modules/scheduler": {
2485
+ "version": "0.27.0",
2486
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
2487
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
2488
+ "license": "MIT"
2489
+ },
2490
+ "node_modules/semver": {
2491
+ "version": "6.3.1",
2492
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
2493
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
2494
+ "dev": true,
2495
+ "license": "ISC",
2496
+ "bin": {
2497
+ "semver": "bin/semver.js"
2498
+ }
2499
+ },
2500
+ "node_modules/siginfo": {
2501
+ "version": "2.0.0",
2502
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
2503
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
2504
+ "dev": true,
2505
+ "license": "ISC"
2506
+ },
2507
+ "node_modules/source-map-js": {
2508
+ "version": "1.2.1",
2509
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
2510
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
2511
+ "dev": true,
2512
+ "license": "BSD-3-Clause",
2513
+ "engines": {
2514
+ "node": ">=0.10.0"
2515
+ }
2516
+ },
2517
+ "node_modules/stackback": {
2518
+ "version": "0.0.2",
2519
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
2520
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
2521
+ "dev": true,
2522
+ "license": "MIT"
2523
+ },
2524
+ "node_modules/std-env": {
2525
+ "version": "3.10.0",
2526
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
2527
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
2528
+ "dev": true,
2529
+ "license": "MIT"
2530
+ },
2531
+ "node_modules/tailwindcss": {
2532
+ "version": "4.2.1",
2533
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
2534
+ "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
2535
+ "dev": true,
2536
+ "license": "MIT"
2537
+ },
2538
+ "node_modules/tapable": {
2539
+ "version": "2.3.0",
2540
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
2541
+ "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
2542
+ "dev": true,
2543
+ "license": "MIT",
2544
+ "engines": {
2545
+ "node": ">=6"
2546
+ },
2547
+ "funding": {
2548
+ "type": "opencollective",
2549
+ "url": "https://opencollective.com/webpack"
2550
+ }
2551
+ },
2552
+ "node_modules/tinybench": {
2553
+ "version": "2.9.0",
2554
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
2555
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
2556
+ "dev": true,
2557
+ "license": "MIT"
2558
+ },
2559
+ "node_modules/tinyexec": {
2560
+ "version": "1.0.2",
2561
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
2562
+ "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
2563
+ "dev": true,
2564
+ "license": "MIT",
2565
+ "engines": {
2566
+ "node": ">=18"
2567
+ }
2568
+ },
2569
+ "node_modules/tinyglobby": {
2570
+ "version": "0.2.15",
2571
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
2572
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
2573
+ "dev": true,
2574
+ "license": "MIT",
2575
+ "dependencies": {
2576
+ "fdir": "^6.5.0",
2577
+ "picomatch": "^4.0.3"
2578
+ },
2579
+ "engines": {
2580
+ "node": ">=12.0.0"
2581
+ },
2582
+ "funding": {
2583
+ "url": "https://github.com/sponsors/SuperchupuDev"
2584
+ }
2585
+ },
2586
+ "node_modules/tinyrainbow": {
2587
+ "version": "3.0.3",
2588
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
2589
+ "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
2590
+ "dev": true,
2591
+ "license": "MIT",
2592
+ "engines": {
2593
+ "node": ">=14.0.0"
2594
+ }
2595
+ },
2596
+ "node_modules/typescript": {
2597
+ "version": "5.9.3",
2598
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
2599
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
2600
+ "dev": true,
2601
+ "license": "Apache-2.0",
2602
+ "bin": {
2603
+ "tsc": "bin/tsc",
2604
+ "tsserver": "bin/tsserver"
2605
+ },
2606
+ "engines": {
2607
+ "node": ">=14.17"
2608
+ }
2609
+ },
2610
+ "node_modules/undici-types": {
2611
+ "version": "7.18.2",
2612
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
2613
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
2614
+ "dev": true,
2615
+ "license": "MIT"
2616
+ },
2617
+ "node_modules/update-browserslist-db": {
2618
+ "version": "1.2.3",
2619
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
2620
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
2621
+ "dev": true,
2622
+ "funding": [
2623
+ {
2624
+ "type": "opencollective",
2625
+ "url": "https://opencollective.com/browserslist"
2626
+ },
2627
+ {
2628
+ "type": "tidelift",
2629
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
2630
+ },
2631
+ {
2632
+ "type": "github",
2633
+ "url": "https://github.com/sponsors/ai"
2634
+ }
2635
+ ],
2636
+ "license": "MIT",
2637
+ "dependencies": {
2638
+ "escalade": "^3.2.0",
2639
+ "picocolors": "^1.1.1"
2640
+ },
2641
+ "bin": {
2642
+ "update-browserslist-db": "cli.js"
2643
+ },
2644
+ "peerDependencies": {
2645
+ "browserslist": ">= 4.21.0"
2646
+ }
2647
+ },
2648
+ "node_modules/vite": {
2649
+ "version": "7.3.1",
2650
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
2651
+ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
2652
+ "dev": true,
2653
+ "license": "MIT",
2654
+ "dependencies": {
2655
+ "esbuild": "^0.27.0",
2656
+ "fdir": "^6.5.0",
2657
+ "picomatch": "^4.0.3",
2658
+ "postcss": "^8.5.6",
2659
+ "rollup": "^4.43.0",
2660
+ "tinyglobby": "^0.2.15"
2661
+ },
2662
+ "bin": {
2663
+ "vite": "bin/vite.js"
2664
+ },
2665
+ "engines": {
2666
+ "node": "^20.19.0 || >=22.12.0"
2667
+ },
2668
+ "funding": {
2669
+ "url": "https://github.com/vitejs/vite?sponsor=1"
2670
+ },
2671
+ "optionalDependencies": {
2672
+ "fsevents": "~2.3.3"
2673
+ },
2674
+ "peerDependencies": {
2675
+ "@types/node": "^20.19.0 || >=22.12.0",
2676
+ "jiti": ">=1.21.0",
2677
+ "less": "^4.0.0",
2678
+ "lightningcss": "^1.21.0",
2679
+ "sass": "^1.70.0",
2680
+ "sass-embedded": "^1.70.0",
2681
+ "stylus": ">=0.54.8",
2682
+ "sugarss": "^5.0.0",
2683
+ "terser": "^5.16.0",
2684
+ "tsx": "^4.8.1",
2685
+ "yaml": "^2.4.2"
2686
+ },
2687
+ "peerDependenciesMeta": {
2688
+ "@types/node": {
2689
+ "optional": true
2690
+ },
2691
+ "jiti": {
2692
+ "optional": true
2693
+ },
2694
+ "less": {
2695
+ "optional": true
2696
+ },
2697
+ "lightningcss": {
2698
+ "optional": true
2699
+ },
2700
+ "sass": {
2701
+ "optional": true
2702
+ },
2703
+ "sass-embedded": {
2704
+ "optional": true
2705
+ },
2706
+ "stylus": {
2707
+ "optional": true
2708
+ },
2709
+ "sugarss": {
2710
+ "optional": true
2711
+ },
2712
+ "terser": {
2713
+ "optional": true
2714
+ },
2715
+ "tsx": {
2716
+ "optional": true
2717
+ },
2718
+ "yaml": {
2719
+ "optional": true
2720
+ }
2721
+ }
2722
+ },
2723
+ "node_modules/vitest": {
2724
+ "version": "4.0.18",
2725
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
2726
+ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
2727
+ "dev": true,
2728
+ "license": "MIT",
2729
+ "dependencies": {
2730
+ "@vitest/expect": "4.0.18",
2731
+ "@vitest/mocker": "4.0.18",
2732
+ "@vitest/pretty-format": "4.0.18",
2733
+ "@vitest/runner": "4.0.18",
2734
+ "@vitest/snapshot": "4.0.18",
2735
+ "@vitest/spy": "4.0.18",
2736
+ "@vitest/utils": "4.0.18",
2737
+ "es-module-lexer": "^1.7.0",
2738
+ "expect-type": "^1.2.2",
2739
+ "magic-string": "^0.30.21",
2740
+ "obug": "^2.1.1",
2741
+ "pathe": "^2.0.3",
2742
+ "picomatch": "^4.0.3",
2743
+ "std-env": "^3.10.0",
2744
+ "tinybench": "^2.9.0",
2745
+ "tinyexec": "^1.0.2",
2746
+ "tinyglobby": "^0.2.15",
2747
+ "tinyrainbow": "^3.0.3",
2748
+ "vite": "^6.0.0 || ^7.0.0",
2749
+ "why-is-node-running": "^2.3.0"
2750
+ },
2751
+ "bin": {
2752
+ "vitest": "vitest.mjs"
2753
+ },
2754
+ "engines": {
2755
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
2756
+ },
2757
+ "funding": {
2758
+ "url": "https://opencollective.com/vitest"
2759
+ },
2760
+ "peerDependencies": {
2761
+ "@edge-runtime/vm": "*",
2762
+ "@opentelemetry/api": "^1.9.0",
2763
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
2764
+ "@vitest/browser-playwright": "4.0.18",
2765
+ "@vitest/browser-preview": "4.0.18",
2766
+ "@vitest/browser-webdriverio": "4.0.18",
2767
+ "@vitest/ui": "4.0.18",
2768
+ "happy-dom": "*",
2769
+ "jsdom": "*"
2770
+ },
2771
+ "peerDependenciesMeta": {
2772
+ "@edge-runtime/vm": {
2773
+ "optional": true
2774
+ },
2775
+ "@opentelemetry/api": {
2776
+ "optional": true
2777
+ },
2778
+ "@types/node": {
2779
+ "optional": true
2780
+ },
2781
+ "@vitest/browser-playwright": {
2782
+ "optional": true
2783
+ },
2784
+ "@vitest/browser-preview": {
2785
+ "optional": true
2786
+ },
2787
+ "@vitest/browser-webdriverio": {
2788
+ "optional": true
2789
+ },
2790
+ "@vitest/ui": {
2791
+ "optional": true
2792
+ },
2793
+ "happy-dom": {
2794
+ "optional": true
2795
+ },
2796
+ "jsdom": {
2797
+ "optional": true
2798
+ }
2799
+ }
2800
+ },
2801
+ "node_modules/why-is-node-running": {
2802
+ "version": "2.3.0",
2803
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
2804
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
2805
+ "dev": true,
2806
+ "license": "MIT",
2807
+ "dependencies": {
2808
+ "siginfo": "^2.0.0",
2809
+ "stackback": "0.0.2"
2810
+ },
2811
+ "bin": {
2812
+ "why-is-node-running": "cli.js"
2813
+ },
2814
+ "engines": {
2815
+ "node": ">=8"
2816
+ }
2817
+ },
2818
+ "node_modules/yallist": {
2819
+ "version": "3.1.1",
2820
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
2821
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
2822
+ "dev": true,
2823
+ "license": "ISC"
2824
+ }
2825
+ }
2826
+ }
package.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "ltmarx",
3
+ "version": "1.0.0",
4
+ "description": "Video watermarking system with imperceptible 32-bit payload embedding",
5
+ "type": "module",
6
+ "bin": {
7
+ "ltmarx": "./dist/server/cli.js"
8
+ },
9
+ "scripts": {
10
+ "dev": "vite",
11
+ "build": "tsc && vite build",
12
+ "build:cli": "tsc -p tsconfig.server.json",
13
+ "test": "vitest run",
14
+ "test:watch": "vitest"
15
+ },
16
+ "license": "MIT",
17
+ "devDependencies": {
18
+ "@tailwindcss/vite": "^4.2.1",
19
+ "@types/node": "^25.3.0",
20
+ "@types/react": "^19.2.14",
21
+ "@types/react-dom": "^19.2.3",
22
+ "@vitejs/plugin-react": "^5.1.4",
23
+ "tailwindcss": "^4.2.1",
24
+ "typescript": "^5.9.3",
25
+ "vite": "^7.3.1",
26
+ "vitest": "^4.0.18"
27
+ },
28
+ "dependencies": {
29
+ "@ffmpeg/ffmpeg": "^0.12.15",
30
+ "@ffmpeg/util": "^0.12.2",
31
+ "react": "^19.2.4",
32
+ "react-dom": "^19.2.4"
33
+ }
34
+ }
server/api.ts ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Optional HTTP API for watermark embedding/detection
3
+ *
4
+ * Serves the web UI and provides API endpoints for server-side processing.
5
+ * Used for HuggingFace Space deployment.
6
+ */
7
+
8
+ import { createServer } from 'node:http';
9
+ import { readFile, stat } from 'node:fs/promises';
10
+ import { join, extname } from 'node:path';
11
+ import { tmpdir } from 'node:os';
12
+ import { randomUUID } from 'node:crypto';
13
+ import { writeFile, unlink } from 'node:fs/promises';
14
+ import { probeVideo, readYuvFrames, createEncoder } from './ffmpeg-io.js';
15
+ import { embedWatermark } from '../core/embedder.js';
16
+ import { detectWatermark, detectWatermarkMultiFrame } from '../core/detector.js';
17
+ import { getPreset } from '../core/presets.js';
18
+ import type { PresetName } from '../core/types.js';
19
+
20
+ const MIME_TYPES: Record<string, string> = {
21
+ '.html': 'text/html',
22
+ '.js': 'application/javascript',
23
+ '.css': 'text/css',
24
+ '.json': 'application/json',
25
+ '.png': 'image/png',
26
+ '.svg': 'image/svg+xml',
27
+ '.woff2': 'font/woff2',
28
+ };
29
+
30
+ const PORT = parseInt(process.env.PORT || '7860', 10);
31
+ const STATIC_DIR = process.env.STATIC_DIR || join(import.meta.dirname || '.', '../dist/web');
32
+
33
+ async function serveStatic(url: string): Promise<{ data: Buffer; contentType: string } | null> {
34
+ const safePath = url.replace(/\.\./g, '').replace(/\/+/g, '/');
35
+ const filePath = join(STATIC_DIR, safePath === '/' ? 'index.html' : safePath);
36
+
37
+ try {
38
+ const s = await stat(filePath);
39
+ if (!s.isFile()) return null;
40
+ const data = await readFile(filePath);
41
+ const ext = extname(filePath);
42
+ return { data, contentType: MIME_TYPES[ext] || 'application/octet-stream' };
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ const server = createServer(async (req, res) => {
49
+ const url = new URL(req.url || '/', `http://localhost:${PORT}`);
50
+
51
+ // CORS + SharedArrayBuffer headers (required for ffmpeg.wasm)
52
+ res.setHeader('Access-Control-Allow-Origin', '*');
53
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
54
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
55
+ res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
56
+ res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
57
+
58
+ if (req.method === 'OPTIONS') {
59
+ res.writeHead(200);
60
+ res.end();
61
+ return;
62
+ }
63
+
64
+ // API: Health check
65
+ if (url.pathname === '/api/health') {
66
+ res.writeHead(200, { 'Content-Type': 'application/json' });
67
+ res.end(JSON.stringify({ status: 'ok' }));
68
+ return;
69
+ }
70
+
71
+ // API: Embed
72
+ if (url.pathname === '/api/embed' && req.method === 'POST') {
73
+ try {
74
+ const chunks: Buffer[] = [];
75
+ for await (const chunk of req) chunks.push(chunk as Buffer);
76
+ const body = Buffer.concat(chunks);
77
+
78
+ // Parse multipart or raw JSON
79
+ const contentType = req.headers['content-type'] || '';
80
+
81
+ if (contentType.includes('application/json')) {
82
+ const { videoBase64, key, preset, payload } = JSON.parse(body.toString());
83
+
84
+ const videoBuffer = Buffer.from(videoBase64, 'base64');
85
+ const inputPath = join(tmpdir(), `ltmarx-in-${randomUUID()}.mp4`);
86
+ const outputPath = join(tmpdir(), `ltmarx-out-${randomUUID()}.mp4`);
87
+
88
+ await writeFile(inputPath, videoBuffer);
89
+
90
+ const config = getPreset((preset || 'moderate') as PresetName);
91
+ const payloadBytes = hexToBytes(payload || 'DEADBEEF');
92
+ const info = await probeVideo(inputPath);
93
+
94
+ const encoder = createEncoder(outputPath, info.width, info.height, info.fps);
95
+ const ySize = info.width * info.height;
96
+ const uvSize = (info.width / 2) * (info.height / 2);
97
+ let totalPsnr = 0;
98
+ let frameCount = 0;
99
+
100
+ for await (const frame of readYuvFrames(inputPath, info.width, info.height)) {
101
+ const result = embedWatermark(frame.y, info.width, info.height, payloadBytes, key, config);
102
+ totalPsnr += result.psnr;
103
+
104
+ const yuvFrame = Buffer.alloc(ySize + 2 * uvSize);
105
+ yuvFrame.set(result.yPlane, 0);
106
+ yuvFrame.set(frame.u, ySize);
107
+ yuvFrame.set(frame.v, ySize + uvSize);
108
+ encoder.stdin.write(yuvFrame);
109
+ frameCount++;
110
+ }
111
+
112
+ encoder.stdin.end();
113
+ await new Promise<void>((resolve) => encoder.process.on('close', () => resolve()));
114
+
115
+ const outputBuffer = await readFile(outputPath);
116
+
117
+ // Cleanup temp files
118
+ await unlink(inputPath).catch(() => {});
119
+ await unlink(outputPath).catch(() => {});
120
+
121
+ res.writeHead(200, { 'Content-Type': 'application/json' });
122
+ res.end(JSON.stringify({
123
+ videoBase64: outputBuffer.toString('base64'),
124
+ frames: frameCount,
125
+ avgPsnr: totalPsnr / frameCount,
126
+ }));
127
+ } else {
128
+ res.writeHead(400, { 'Content-Type': 'application/json' });
129
+ res.end(JSON.stringify({ error: 'Expected application/json content type' }));
130
+ }
131
+ } catch (e) {
132
+ res.writeHead(500, { 'Content-Type': 'application/json' });
133
+ res.end(JSON.stringify({ error: String(e) }));
134
+ }
135
+ return;
136
+ }
137
+
138
+ // API: Detect
139
+ if (url.pathname === '/api/detect' && req.method === 'POST') {
140
+ try {
141
+ const chunks: Buffer[] = [];
142
+ for await (const chunk of req) chunks.push(chunk as Buffer);
143
+ const body = JSON.parse(Buffer.concat(chunks).toString());
144
+
145
+ const { videoBase64, key, preset, frames: maxFrames } = body;
146
+ const videoBuffer = Buffer.from(videoBase64, 'base64');
147
+ const inputPath = join(tmpdir(), `ltmarx-det-${randomUUID()}.mp4`);
148
+
149
+ await writeFile(inputPath, videoBuffer);
150
+
151
+ const config = getPreset((preset || 'moderate') as PresetName);
152
+ const info = await probeVideo(inputPath);
153
+ const framesToRead = Math.min(maxFrames || 10, info.totalFrames);
154
+
155
+ const yPlanes: Uint8Array[] = [];
156
+ let count = 0;
157
+ for await (const frame of readYuvFrames(inputPath, info.width, info.height)) {
158
+ yPlanes.push(new Uint8Array(frame.y));
159
+ count++;
160
+ if (count >= framesToRead) break;
161
+ }
162
+
163
+ await unlink(inputPath).catch(() => {});
164
+
165
+ const result = detectWatermarkMultiFrame(yPlanes, info.width, info.height, key, config);
166
+
167
+ res.writeHead(200, { 'Content-Type': 'application/json' });
168
+ res.end(JSON.stringify({
169
+ detected: result.detected,
170
+ payload: result.payload ? bytesToHex(result.payload) : null,
171
+ confidence: result.confidence,
172
+ tilesDecoded: result.tilesDecoded,
173
+ tilesTotal: result.tilesTotal,
174
+ }));
175
+ } catch (e) {
176
+ res.writeHead(500, { 'Content-Type': 'application/json' });
177
+ res.end(JSON.stringify({ error: String(e) }));
178
+ }
179
+ return;
180
+ }
181
+
182
+ // Static file serving
183
+ const staticResult = await serveStatic(url.pathname);
184
+ if (staticResult) {
185
+ res.writeHead(200, { 'Content-Type': staticResult.contentType });
186
+ res.end(staticResult.data);
187
+ return;
188
+ }
189
+
190
+ // SPA fallback
191
+ const indexResult = await serveStatic('/');
192
+ if (indexResult) {
193
+ res.writeHead(200, { 'Content-Type': 'text/html' });
194
+ res.end(indexResult.data);
195
+ return;
196
+ }
197
+
198
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
199
+ res.end('Not Found');
200
+ });
201
+
202
+ server.listen(PORT, () => {
203
+ console.log(`LTMarx server listening on http://localhost:${PORT}`);
204
+ });
205
+
206
+ function hexToBytes(hex: string): Uint8Array {
207
+ const clean = hex.replace(/^0x/, '');
208
+ const padded = clean.length % 2 ? '0' + clean : clean;
209
+ const bytes = new Uint8Array(padded.length / 2);
210
+ for (let i = 0; i < bytes.length; i++) {
211
+ bytes[i] = parseInt(padded.slice(i * 2, i * 2 + 2), 16);
212
+ }
213
+ return bytes;
214
+ }
215
+
216
+ function bytesToHex(bytes: Uint8Array): string {
217
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').toUpperCase();
218
+ }
server/cli.ts ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * LTMarx CLI — Video watermark embedding and detection
5
+ *
6
+ * Usage:
7
+ * ltmarx embed -i input.mp4 -o output.mp4 --key SECRET --preset moderate
8
+ * ltmarx detect -i video.mp4 --key SECRET --preset moderate
9
+ */
10
+
11
+ import { parseArgs } from 'node:util';
12
+ import { probeVideo, readYuvFrames, createEncoder } from './ffmpeg-io.js';
13
+ import { embedWatermark } from '../core/embedder.js';
14
+ import { detectWatermark, detectWatermarkMultiFrame } from '../core/detector.js';
15
+ import { getPreset, PRESET_DESCRIPTIONS } from '../core/presets.js';
16
+ import type { PresetName } from '../core/types.js';
17
+
18
+ function usage() {
19
+ console.log(`
20
+ LTMarx — Video Watermarking System
21
+
22
+ Commands:
23
+ embed Embed a watermark into a video
24
+ detect Detect and extract a watermark from a video
25
+ presets List available presets
26
+
27
+ Usage:
28
+ ltmarx embed -i input.mp4 -o output.mp4 --key SECRET --preset moderate --payload DEADBEEF
29
+ ltmarx detect -i video.mp4 --key SECRET --preset moderate [--frames N]
30
+ ltmarx presets
31
+
32
+ Options:
33
+ -i, --input Input video file
34
+ -o, --output Output video file (embed only)
35
+ --key Secret key for watermark
36
+ --preset Preset name: light, moderate, strong, fortress
37
+ --payload 32-bit payload as hex string (embed only, default: DEADBEEF)
38
+ --frames Number of frames to analyze (detect only, default: 10)
39
+ --crf Output CRF quality (embed only, default: 18)
40
+ `);
41
+ process.exit(1);
42
+ }
43
+
44
+ async function main() {
45
+ const args = process.argv.slice(2);
46
+ const command = args[0];
47
+
48
+ if (!command || command === '--help' || command === '-h') usage();
49
+
50
+ if (command === 'presets') {
51
+ console.log('\nAvailable presets:\n');
52
+ for (const [name, desc] of Object.entries(PRESET_DESCRIPTIONS)) {
53
+ console.log(` ${name.padEnd(12)} ${desc}`);
54
+ }
55
+ console.log();
56
+ process.exit(0);
57
+ }
58
+
59
+ const { values } = parseArgs({
60
+ args: args.slice(1),
61
+ options: {
62
+ input: { type: 'string', short: 'i' },
63
+ output: { type: 'string', short: 'o' },
64
+ key: { type: 'string' },
65
+ preset: { type: 'string' },
66
+ payload: { type: 'string' },
67
+ frames: { type: 'string' },
68
+ crf: { type: 'string' },
69
+ },
70
+ });
71
+
72
+ const input = values.input;
73
+ const key = values.key;
74
+ const presetName = (values.preset || 'moderate') as PresetName;
75
+
76
+ if (!input) { console.error('Error: --input is required'); process.exit(1); }
77
+ if (!key) { console.error('Error: --key is required'); process.exit(1); }
78
+
79
+ const config = getPreset(presetName);
80
+
81
+ if (command === 'embed') {
82
+ const output = values.output;
83
+ if (!output) { console.error('Error: --output is required for embed'); process.exit(1); }
84
+
85
+ const payloadHex = values.payload || 'DEADBEEF';
86
+ const payload = hexToBytes(payloadHex);
87
+ const crf = parseInt(values.crf || '18', 10);
88
+
89
+ console.log(`Embedding watermark...`);
90
+ console.log(` Input: ${input}`);
91
+ console.log(` Output: ${output}`);
92
+ console.log(` Preset: ${presetName}`);
93
+ console.log(` Payload: ${payloadHex}`);
94
+ console.log(` CRF: ${crf}`);
95
+
96
+ const info = await probeVideo(input);
97
+ console.log(` Video: ${info.width}x${info.height} @ ${info.fps.toFixed(2)} fps, ${info.totalFrames} frames`);
98
+
99
+ const encoder = createEncoder(output, info.width, info.height, info.fps, crf);
100
+ let frameCount = 0;
101
+ let totalPsnr = 0;
102
+
103
+ const ySize = info.width * info.height;
104
+ const uvSize = (info.width / 2) * (info.height / 2);
105
+
106
+ for await (const frame of readYuvFrames(input, info.width, info.height)) {
107
+ const result = embedWatermark(frame.y, info.width, info.height, payload, key, config);
108
+ totalPsnr += result.psnr;
109
+
110
+ // Write YUV420p frame: watermarked Y + original U + V
111
+ const yuvFrame = Buffer.alloc(ySize + 2 * uvSize);
112
+ yuvFrame.set(result.yPlane, 0);
113
+ yuvFrame.set(frame.u, ySize);
114
+ yuvFrame.set(frame.v, ySize + uvSize);
115
+
116
+ encoder.stdin.write(yuvFrame);
117
+ frameCount++;
118
+
119
+ if (frameCount % 30 === 0) {
120
+ process.stdout.write(`\r Progress: ${frameCount} frames (PSNR: ${(totalPsnr / frameCount).toFixed(1)} dB)`);
121
+ }
122
+ }
123
+
124
+ encoder.stdin.end();
125
+ await new Promise<void>((resolve) => encoder.process.on('close', () => resolve()));
126
+
127
+ console.log(`\r Complete: ${frameCount} frames, avg PSNR: ${(totalPsnr / frameCount).toFixed(1)} dB`);
128
+ console.log(` Output saved to: ${output}`);
129
+
130
+ } else if (command === 'detect') {
131
+ const maxFrames = parseInt(values.frames || '10', 10);
132
+
133
+ console.log(`Detecting watermark...`);
134
+ console.log(` Input: ${input}`);
135
+ console.log(` Preset: ${presetName}`);
136
+ console.log(` Frames: ${maxFrames}`);
137
+
138
+ const info = await probeVideo(input);
139
+ console.log(` Video: ${info.width}x${info.height} @ ${info.fps.toFixed(2)} fps`);
140
+
141
+ const yPlanes: Uint8Array[] = [];
142
+ let frameCount = 0;
143
+
144
+ for await (const frame of readYuvFrames(input, info.width, info.height)) {
145
+ yPlanes.push(new Uint8Array(frame.y));
146
+ frameCount++;
147
+ if (frameCount >= maxFrames) break;
148
+ process.stdout.write(`\r Reading frame ${frameCount}/${maxFrames}...`);
149
+ }
150
+
151
+ console.log(`\r Analyzing ${yPlanes.length} frames...`);
152
+
153
+ // Try multi-frame detection first
154
+ if (yPlanes.length > 1) {
155
+ const result = detectWatermarkMultiFrame(yPlanes, info.width, info.height, key, config);
156
+ if (result.detected) {
157
+ console.log(`\n WATERMARK DETECTED (multi-frame)`);
158
+ console.log(` Payload: ${bytesToHex(result.payload!)}`);
159
+ console.log(` Confidence: ${(result.confidence * 100).toFixed(1)}%`);
160
+ console.log(` Tiles: ${result.tilesDecoded}/${result.tilesTotal}`);
161
+ process.exit(0);
162
+ }
163
+ }
164
+
165
+ // Try single-frame detection
166
+ for (let i = 0; i < yPlanes.length; i++) {
167
+ const result = detectWatermark(yPlanes[i], info.width, info.height, key, config);
168
+ if (result.detected) {
169
+ console.log(`\n WATERMARK DETECTED (frame ${i + 1})`);
170
+ console.log(` Payload: ${bytesToHex(result.payload!)}`);
171
+ console.log(` Confidence: ${(result.confidence * 100).toFixed(1)}%`);
172
+ console.log(` Tiles: ${result.tilesDecoded}/${result.tilesTotal}`);
173
+ process.exit(0);
174
+ }
175
+ }
176
+
177
+ console.log(`\n No watermark detected.`);
178
+ process.exit(1);
179
+
180
+ } else {
181
+ console.error(`Unknown command: ${command}`);
182
+ usage();
183
+ }
184
+ }
185
+
186
+ function hexToBytes(hex: string): Uint8Array {
187
+ const clean = hex.replace(/^0x/, '');
188
+ const padded = clean.length % 2 ? '0' + clean : clean;
189
+ const bytes = new Uint8Array(padded.length / 2);
190
+ for (let i = 0; i < bytes.length; i++) {
191
+ bytes[i] = parseInt(padded.slice(i * 2, i * 2 + 2), 16);
192
+ }
193
+ return bytes;
194
+ }
195
+
196
+ function bytesToHex(bytes: Uint8Array): string {
197
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').toUpperCase();
198
+ }
199
+
200
+ main().catch((e) => {
201
+ console.error('Error:', e.message || e);
202
+ process.exit(1);
203
+ });
server/ffmpeg-io.ts ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * FFmpeg-based video I/O for server-side watermark processing
3
+ *
4
+ * Spawns FFmpeg subprocesses to decode/encode raw YUV420p frames.
5
+ */
6
+
7
+ import { spawn, type ChildProcess } from 'node:child_process';
8
+ import { Readable, Writable } from 'node:stream';
9
+
10
+ /** Video metadata */
11
+ export interface VideoInfo {
12
+ width: number;
13
+ height: number;
14
+ fps: number;
15
+ duration: number;
16
+ totalFrames: number;
17
+ }
18
+
19
+ /**
20
+ * Probe video file for metadata using ffprobe
21
+ */
22
+ export async function probeVideo(inputPath: string): Promise<VideoInfo> {
23
+ return new Promise((resolve, reject) => {
24
+ const proc = spawn('ffprobe', [
25
+ '-v', 'quiet',
26
+ '-print_format', 'json',
27
+ '-show_streams',
28
+ '-show_format',
29
+ inputPath,
30
+ ]);
31
+
32
+ let stdout = '';
33
+ let stderr = '';
34
+ proc.stdout.on('data', (d: Buffer) => (stdout += d.toString()));
35
+ proc.stderr.on('data', (d: Buffer) => (stderr += d.toString()));
36
+
37
+ proc.on('close', (code) => {
38
+ if (code !== 0) {
39
+ reject(new Error(`ffprobe failed (${code}): ${stderr}`));
40
+ return;
41
+ }
42
+ try {
43
+ const info = JSON.parse(stdout);
44
+ const videoStream = info.streams?.find((s: { codec_type: string }) => s.codec_type === 'video');
45
+ if (!videoStream) throw new Error('No video stream found');
46
+
47
+ const [num, den] = (videoStream.r_frame_rate || '30/1').split('/').map(Number);
48
+ const fps = den ? num / den : 30;
49
+ const duration = parseFloat(info.format?.duration || videoStream.duration || '0');
50
+ const totalFrames = Math.ceil(fps * duration);
51
+
52
+ resolve({
53
+ width: videoStream.width,
54
+ height: videoStream.height,
55
+ fps,
56
+ duration,
57
+ totalFrames,
58
+ });
59
+ } catch (e) {
60
+ reject(new Error(`Failed to parse ffprobe output: ${e}`));
61
+ }
62
+ });
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Frame reader: decodes a video file to raw Y planes
68
+ * Yields one Y plane (Uint8Array of width*height) per frame
69
+ */
70
+ export async function* readYPlanes(
71
+ inputPath: string,
72
+ width: number,
73
+ height: number
74
+ ): AsyncGenerator<Uint8Array> {
75
+ const frameSize = width * height; // Y plane only
76
+ const yuvFrameSize = frameSize * 3 / 2; // YUV420p: Y + U/4 + V/4
77
+
78
+ const proc = spawn('ffmpeg', [
79
+ '-i', inputPath,
80
+ '-f', 'rawvideo',
81
+ '-pix_fmt', 'yuv420p',
82
+ '-v', 'error',
83
+ 'pipe:1',
84
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
85
+
86
+ let buffer = Buffer.alloc(0);
87
+
88
+ for await (const chunk of proc.stdout as AsyncIterable<Buffer>) {
89
+ buffer = Buffer.concat([buffer, chunk]);
90
+
91
+ while (buffer.length >= yuvFrameSize) {
92
+ // Extract Y plane (first width*height bytes of YUV420p frame)
93
+ const yPlane = new Uint8Array(buffer.subarray(0, frameSize));
94
+ yield yPlane;
95
+ buffer = buffer.subarray(yuvFrameSize);
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Create a write pipe to FFmpeg for encoding watermarked frames
102
+ * Returns a writable stream that accepts YUV420p frame data
103
+ */
104
+ export function createEncoder(
105
+ outputPath: string,
106
+ width: number,
107
+ height: number,
108
+ fps: number,
109
+ crf: number = 18
110
+ ): { stdin: Writable; process: ChildProcess } {
111
+ const proc = spawn('ffmpeg', [
112
+ '-y',
113
+ '-f', 'rawvideo',
114
+ '-pix_fmt', 'yuv420p',
115
+ '-s', `${width}x${height}`,
116
+ '-r', String(fps),
117
+ '-i', 'pipe:0',
118
+ '-c:v', 'libx264',
119
+ '-crf', String(crf),
120
+ '-preset', 'medium',
121
+ '-pix_fmt', 'yuv420p',
122
+ '-v', 'error',
123
+ outputPath,
124
+ ], { stdio: ['pipe', 'ignore', 'pipe'] });
125
+
126
+ return { stdin: proc.stdin, process: proc };
127
+ }
128
+
129
+ /**
130
+ * Read all YUV420p frames from video and provide full frame buffers
131
+ * (Y, U, V planes) for pass-through of chroma channels
132
+ */
133
+ export async function* readYuvFrames(
134
+ inputPath: string,
135
+ width: number,
136
+ height: number
137
+ ): AsyncGenerator<{ y: Uint8Array; u: Uint8Array; v: Uint8Array }> {
138
+ const ySize = width * height;
139
+ const uvSize = (width / 2) * (height / 2);
140
+ const frameSize = ySize + 2 * uvSize;
141
+
142
+ const proc = spawn('ffmpeg', [
143
+ '-i', inputPath,
144
+ '-f', 'rawvideo',
145
+ '-pix_fmt', 'yuv420p',
146
+ '-v', 'error',
147
+ 'pipe:1',
148
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
149
+
150
+ let buffer = Buffer.alloc(0);
151
+
152
+ for await (const chunk of proc.stdout as AsyncIterable<Buffer>) {
153
+ buffer = Buffer.concat([buffer, chunk]);
154
+
155
+ while (buffer.length >= frameSize) {
156
+ const y = new Uint8Array(buffer.subarray(0, ySize));
157
+ const u = new Uint8Array(buffer.subarray(ySize, ySize + uvSize));
158
+ const v = new Uint8Array(buffer.subarray(ySize + uvSize, frameSize));
159
+ yield { y, u, v };
160
+ buffer = buffer.subarray(frameSize);
161
+ }
162
+ }
163
+ }
tsconfig.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "noEmit": true,
15
+ "declaration": true,
16
+ "declarationMap": true,
17
+ "sourceMap": true,
18
+ "outDir": "./dist",
19
+ "rootDir": ".",
20
+ "baseUrl": ".",
21
+ "paths": {
22
+ "@core/*": ["./core/*"]
23
+ }
24
+ },
25
+ "include": ["core/**/*.ts", "web/src/**/*.ts", "web/src/**/*.tsx", "server/**/*.ts", "tests/**/*.ts"],
26
+ "exclude": ["node_modules", "dist"]
27
+ }
tsconfig.server.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022"],
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "resolveJsonModule": true,
12
+ "isolatedModules": true,
13
+ "declaration": true,
14
+ "sourceMap": true,
15
+ "outDir": "./dist",
16
+ "rootDir": ".",
17
+ "baseUrl": ".",
18
+ "paths": {
19
+ "@core/*": ["./core/*"]
20
+ }
21
+ },
22
+ "include": ["core/**/*.ts", "server/**/*.ts"],
23
+ "exclude": ["node_modules", "dist"]
24
+ }
vite.config.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+ import tailwindcss from '@tailwindcss/vite';
4
+ import { resolve } from 'path';
5
+
6
+ export default defineConfig({
7
+ plugins: [react(), tailwindcss()],
8
+ root: 'web',
9
+ resolve: {
10
+ alias: {
11
+ '@core': resolve(__dirname, 'core'),
12
+ },
13
+ },
14
+ build: {
15
+ outDir: '../dist/web',
16
+ emptyOutDir: true,
17
+ },
18
+ worker: {
19
+ format: 'es',
20
+ },
21
+ server: {
22
+ headers: {
23
+ // Required for ffmpeg.wasm SharedArrayBuffer support
24
+ 'Cross-Origin-Opener-Policy': 'same-origin',
25
+ 'Cross-Origin-Embedder-Policy': 'require-corp',
26
+ },
27
+ },
28
+ optimizeDeps: {
29
+ exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util'],
30
+ },
31
+ });
web/.DS_Store ADDED
Binary file (6.15 kB). View file
 
web/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>LTMarX — Video Watermarking</title>
7
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>&#x1f3ac;</text></svg>" />
8
+ </head>
9
+ <body class="bg-zinc-950 text-zinc-100 antialiased">
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
web/src/.DS_Store ADDED
Binary file (6.15 kB). View file
 
web/src/App.tsx ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 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
28
+ onClick={() => setTab('embed')}
29
+ className={`rounded-lg px-4 py-1.5 text-sm font-medium transition-all ${
30
+ tab === 'embed'
31
+ ? 'bg-zinc-800 text-zinc-100 shadow-sm'
32
+ : 'text-zinc-400 hover:text-zinc-200'
33
+ }`}
34
+ >
35
+ 🎨 Embed
36
+ </button>
37
+ <button
38
+ onClick={() => setTab('detect')}
39
+ className={`rounded-lg px-4 py-1.5 text-sm font-medium transition-all ${
40
+ tab === 'detect'
41
+ ? 'bg-zinc-800 text-zinc-100 shadow-sm'
42
+ : 'text-zinc-400 hover:text-zinc-200'
43
+ }`}
44
+ >
45
+ 🔍 Detect
46
+ </button>
47
+ <button
48
+ onClick={() => setTab('api')}
49
+ className={`rounded-lg px-4 py-1.5 text-sm font-medium transition-all ${
50
+ tab === 'api'
51
+ ? 'bg-zinc-800 text-zinc-100 shadow-sm'
52
+ : 'text-zinc-400 hover:text-zinc-200'
53
+ }`}
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>
85
+ </footer>
86
+ </div>
87
+ );
88
+ }
web/src/components/ApiDocs.tsx ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+
3
+ interface CodeBlockProps {
4
+ lang: string;
5
+ children: string;
6
+ }
7
+
8
+ function CodeBlock({ lang, children }: CodeBlockProps) {
9
+ const [copied, setCopied] = useState(false);
10
+ const copy = () => {
11
+ navigator.clipboard.writeText(children.trim());
12
+ setCopied(true);
13
+ setTimeout(() => setCopied(false), 1500);
14
+ };
15
+ return (
16
+ <div className="group relative">
17
+ <div className="flex items-center justify-between rounded-t-lg bg-zinc-800/80 px-3 py-1.5">
18
+ <span className="text-[10px] font-medium uppercase tracking-wider text-zinc-500">{lang}</span>
19
+ <button
20
+ onClick={copy}
21
+ className="text-[10px] text-zinc-500 transition-colors hover:text-zinc-300"
22
+ >
23
+ {copied ? 'Copied!' : 'Copy'}
24
+ </button>
25
+ </div>
26
+ <pre className="overflow-x-auto rounded-b-lg bg-zinc-900/80 p-3 text-xs leading-relaxed text-zinc-300 ring-1 ring-zinc-800/50">
27
+ <code>{children.trim()}</code>
28
+ </pre>
29
+ </div>
30
+ );
31
+ }
32
+
33
+ function Section({ title, children }: { title: string; children: React.ReactNode }) {
34
+ return (
35
+ <div className="space-y-3">
36
+ <h3 className="text-sm font-semibold text-zinc-200">{title}</h3>
37
+ {children}
38
+ </div>
39
+ );
40
+ }
41
+
42
+ function Endpoint({ method, path, desc }: { method: string; path: string; desc: string }) {
43
+ const color = method === 'POST' ? 'text-amber-400 bg-amber-400/10' : 'text-emerald-400 bg-emerald-400/10';
44
+ return (
45
+ <div className="flex items-start gap-2">
46
+ <span className={`mt-0.5 shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold ${color}`}>{method}</span>
47
+ <div>
48
+ <code className="text-xs font-medium text-zinc-200">{path}</code>
49
+ <p className="mt-0.5 text-xs text-zinc-500">{desc}</p>
50
+ </div>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ function ParamTable({ params }: { params: { name: string; type: string; desc: string; required?: boolean }[] }) {
56
+ return (
57
+ <div className="overflow-hidden rounded-lg ring-1 ring-zinc-800/50">
58
+ <table className="w-full text-xs">
59
+ <thead>
60
+ <tr className="border-b border-zinc-800/50 bg-zinc-900/50">
61
+ <th className="px-3 py-1.5 text-left font-medium text-zinc-400">Parameter</th>
62
+ <th className="px-3 py-1.5 text-left font-medium text-zinc-400">Type</th>
63
+ <th className="px-3 py-1.5 text-left font-medium text-zinc-400">Description</th>
64
+ </tr>
65
+ </thead>
66
+ <tbody>
67
+ {params.map((p) => (
68
+ <tr key={p.name} className="border-b border-zinc-800/30 last:border-0">
69
+ <td className="px-3 py-1.5">
70
+ <code className="text-zinc-200">{p.name}</code>
71
+ {p.required && <span className="ml-1 text-[9px] text-red-400">*</span>}
72
+ </td>
73
+ <td className="px-3 py-1.5 text-zinc-500">{p.type}</td>
74
+ <td className="px-3 py-1.5 text-zinc-400">{p.desc}</td>
75
+ </tr>
76
+ ))}
77
+ </tbody>
78
+ </table>
79
+ </div>
80
+ );
81
+ }
82
+
83
+ export default function ApiDocs() {
84
+ return (
85
+ <div className="space-y-8">
86
+ {/* Core Library */}
87
+ <Section title="Core Library (TypeScript)">
88
+ <p className="text-xs text-zinc-400">
89
+ The core engine is isomorphic — same code runs in the browser and on Node.js.
90
+ It operates on raw Y-plane (luminance) buffers with no platform dependencies.
91
+ </p>
92
+
93
+ <div className="space-y-4">
94
+ <div className="space-y-2">
95
+ <h4 className="text-xs font-medium text-zinc-300">Embed a watermark</h4>
96
+ <CodeBlock lang="typescript">{`
97
+ import { embedWatermark } from '@core/embedder';
98
+ import { getPreset } from '@core/presets';
99
+
100
+ const config = getPreset('moderate');
101
+ const payload = new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF]);
102
+
103
+ const result = embedWatermark(yPlane, width, height, payload, secretKey, config);
104
+ // result.yPlane → Uint8Array (watermarked luminance)
105
+ // result.psnr → number (quality in dB, higher = less visible)
106
+ `}</CodeBlock>
107
+ </div>
108
+
109
+ <div className="space-y-2">
110
+ <h4 className="text-xs font-medium text-zinc-300">Detect a watermark</h4>
111
+ <CodeBlock lang="typescript">{`
112
+ import { detectWatermarkMultiFrame } from '@core/detector';
113
+ import { getPreset } from '@core/presets';
114
+
115
+ const config = getPreset('moderate');
116
+ const result = detectWatermarkMultiFrame(yPlanes, width, height, secretKey, config);
117
+ // result.detected → boolean
118
+ // result.payload → Uint8Array | null (the 4-byte payload)
119
+ // result.confidence → number (0–1)
120
+ // result.tilesDecoded → number
121
+ `}</CodeBlock>
122
+ </div>
123
+
124
+ <div className="space-y-2">
125
+ <h4 className="text-xs font-medium text-zinc-300">Auto-detect (tries all presets)</h4>
126
+ <CodeBlock lang="typescript">{`
127
+ import { autoDetectMultiFrame } from '@core/detector';
128
+
129
+ const result = autoDetectMultiFrame(yPlanes, width, height, secretKey);
130
+ // result.presetUsed → 'light' | 'moderate' | 'strong' | 'fortress' | null
131
+ `}</CodeBlock>
132
+ </div>
133
+ </div>
134
+ </Section>
135
+
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">
143
+ <div className="space-y-2">
144
+ <Endpoint method="POST" path="/api/embed" desc="Embed a watermark into a video" />
145
+ <ParamTable params={[
146
+ { name: 'videoBase64', type: 'string', desc: 'Base64-encoded video file', required: true },
147
+ { name: 'key', type: 'string', desc: 'Secret key for embedding', required: true },
148
+ { name: 'preset', type: 'string', desc: 'light | moderate | strong | fortress', required: true },
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",
157
+ "preset": "moderate",
158
+ "payload": "DEADBEEF"
159
+ }'
160
+ `}</CodeBlock>
161
+ </div>
162
+
163
+ <div className="space-y-2">
164
+ <Endpoint method="POST" path="/api/detect" desc="Detect and extract a watermark from a video" />
165
+ <ParamTable params={[
166
+ { name: 'videoBase64', type: 'string', desc: 'Base64-encoded video file', required: true },
167
+ { name: 'key', type: 'string', desc: 'Secret key used during embedding', required: true },
168
+ { name: 'preset', type: 'string', desc: 'Preset to try (omit to try all)', required: false },
169
+ { name: 'frames', type: 'number', desc: 'Max frames to analyze (default: 10)', required: false },
170
+ ]} />
171
+ <CodeBlock lang="json">{`
172
+ {
173
+ "detected": true,
174
+ "payload": "DEADBEEF",
175
+ "confidence": 0.97,
176
+ "preset": "moderate",
177
+ "tilesDecoded": 12,
178
+ "tilesTotal": 16
179
+ }
180
+ `}</CodeBlock>
181
+ </div>
182
+
183
+ <Endpoint method="GET" path="/api/health" desc="Health check — returns { status: 'ok' }" />
184
+ </div>
185
+ </Section>
186
+
187
+ {/* CLI */}
188
+ <Section title="CLI">
189
+ <p className="text-xs text-zinc-400">
190
+ Requires Node.js and FFmpeg installed locally.
191
+ </p>
192
+
193
+ <div className="space-y-2">
194
+ <CodeBlock lang="bash">{`
195
+ # Embed a watermark
196
+ npx tsx server/cli.ts embed \\
197
+ -i input.mp4 -o output.mp4 \\
198
+ --key SECRET --preset moderate --payload DEADBEEF
199
+
200
+ # Detect a watermark (auto-tries all presets)
201
+ npx tsx server/cli.ts detect -i video.mp4 --key SECRET
202
+
203
+ # List available presets
204
+ npx tsx server/cli.ts presets
205
+ `}</CodeBlock>
206
+ </div>
207
+ </Section>
208
+
209
+ {/* Presets reference */}
210
+ <Section title="Presets">
211
+ <div className="overflow-hidden rounded-lg ring-1 ring-zinc-800/50">
212
+ <table className="w-full text-xs">
213
+ <thead>
214
+ <tr className="border-b border-zinc-800/50 bg-zinc-900/50">
215
+ <th className="px-3 py-1.5 text-left font-medium text-zinc-400">Preset</th>
216
+ <th className="px-3 py-1.5 text-left font-medium text-zinc-400">Delta</th>
217
+ <th className="px-3 py-1.5 text-left font-medium text-zinc-400">BCH</th>
218
+ <th className="px-3 py-1.5 text-left font-medium text-zinc-400">Masking</th>
219
+ <th className="px-3 py-1.5 text-left font-medium text-zinc-400">Use case</th>
220
+ </tr>
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) => (
229
+ <tr key={p.name} className="border-b border-zinc-800/30 last:border-0">
230
+ <td className="px-3 py-1.5 font-medium text-zinc-200">{p.name}</td>
231
+ <td className="px-3 py-1.5 tabular-nums text-zinc-400">{p.delta}</td>
232
+ <td className="px-3 py-1.5 font-mono text-zinc-400">{p.bch}</td>
233
+ <td className="px-3 py-1.5 text-zinc-400">{p.mask}</td>
234
+ <td className="px-3 py-1.5 text-zinc-400">{p.use}</td>
235
+ </tr>
236
+ ))}
237
+ </tbody>
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
+ }
web/src/components/ComparisonView.tsx ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef, useEffect, useState } from 'react';
2
+
3
+ interface ComparisonViewProps {
4
+ originalFrames: ImageData[];
5
+ watermarkedFrames: ImageData[];
6
+ width: number;
7
+ height: number;
8
+ fps: number;
9
+ }
10
+
11
+ export default function ComparisonView({
12
+ originalFrames,
13
+ watermarkedFrames,
14
+ width,
15
+ height,
16
+ fps,
17
+ }: ComparisonViewProps) {
18
+ const [mode, setMode] = useState<'side-by-side' | 'difference'>('side-by-side');
19
+ const [amplify, setAmplify] = useState(1);
20
+ const [playing, setPlaying] = useState(false);
21
+ const [currentFrame, setCurrentFrame] = useState(0);
22
+ const diffCanvasRef = useRef<HTMLCanvasElement>(null);
23
+ const origCanvasRef = useRef<HTMLCanvasElement>(null);
24
+ const wmCanvasRef = useRef<HTMLCanvasElement>(null);
25
+ const animRef = useRef<number>(0);
26
+ const lastFrameTime = useRef(0);
27
+
28
+ const totalFrames = originalFrames.length;
29
+
30
+ // Render side-by-side canvases for current frame
31
+ useEffect(() => {
32
+ if (mode !== 'side-by-side') return;
33
+ const origCtx = origCanvasRef.current?.getContext('2d');
34
+ const wmCtx = wmCanvasRef.current?.getContext('2d');
35
+ if (origCtx && originalFrames[currentFrame]) {
36
+ origCtx.putImageData(originalFrames[currentFrame], 0, 0);
37
+ }
38
+ if (wmCtx && watermarkedFrames[currentFrame]) {
39
+ wmCtx.putImageData(watermarkedFrames[currentFrame], 0, 0);
40
+ }
41
+ }, [originalFrames, watermarkedFrames, mode, currentFrame]);
42
+
43
+ // Render diff frame
44
+ useEffect(() => {
45
+ if (mode !== 'difference') return;
46
+ const ctx = diffCanvasRef.current?.getContext('2d');
47
+ if (!ctx || !originalFrames[currentFrame] || !watermarkedFrames[currentFrame]) return;
48
+
49
+ const orig = originalFrames[currentFrame];
50
+ const wm = watermarkedFrames[currentFrame];
51
+ const diff = new ImageData(width, height);
52
+
53
+ for (let i = 0; i < orig.data.length; i += 4) {
54
+ const dr = Math.abs(orig.data[i] - wm.data[i]);
55
+ const dg = Math.abs(orig.data[i + 1] - wm.data[i + 1]);
56
+ const db = Math.abs(orig.data[i + 2] - wm.data[i + 2]);
57
+ const d = Math.max(dr, dg, db);
58
+ const amplified = Math.min(255, d * amplify);
59
+ diff.data[i] = amplified;
60
+ diff.data[i + 1] = 0;
61
+ diff.data[i + 2] = 255 - amplified;
62
+ diff.data[i + 3] = 255;
63
+ }
64
+ ctx.putImageData(diff, 0, 0);
65
+ }, [originalFrames, watermarkedFrames, mode, amplify, currentFrame, width, height]);
66
+
67
+ // Playback loop
68
+ useEffect(() => {
69
+ if (!playing) return;
70
+ const interval = 1000 / Math.min(fps, 5);
71
+
72
+ const step = (time: number) => {
73
+ if (time - lastFrameTime.current >= interval) {
74
+ setCurrentFrame((f) => (f + 1) % totalFrames);
75
+ lastFrameTime.current = time;
76
+ }
77
+ animRef.current = requestAnimationFrame(step);
78
+ };
79
+ animRef.current = requestAnimationFrame(step);
80
+
81
+ return () => cancelAnimationFrame(animRef.current);
82
+ }, [playing, fps, totalFrames]);
83
+
84
+ const aspect = `${width} / ${height}`;
85
+
86
+ return (
87
+ <div className="space-y-3">
88
+ <div className="flex items-center justify-between">
89
+ <div className="flex items-center gap-2">
90
+ <div className="flex gap-1 rounded-md bg-zinc-800/50 p-0.5">
91
+ <button
92
+ onClick={() => setMode('side-by-side')}
93
+ className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${
94
+ mode === 'side-by-side' ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'
95
+ }`}
96
+ >
97
+ Side by Side
98
+ </button>
99
+ <button
100
+ onClick={() => setMode('difference')}
101
+ className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${
102
+ mode === 'difference' ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'
103
+ }`}
104
+ >
105
+ Difference
106
+ </button>
107
+ </div>
108
+
109
+ <button
110
+ onClick={() => setPlaying(!playing)}
111
+ className="rounded-md bg-zinc-800/50 px-2.5 py-1 text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
112
+ >
113
+ {playing ? 'Pause' : 'Play'}
114
+ </button>
115
+ </div>
116
+
117
+ <div className="flex items-center gap-3">
118
+ {mode === 'difference' && (
119
+ <div className="flex items-center gap-2">
120
+ <span className="text-[10px] text-zinc-500">Amplify</span>
121
+ <input
122
+ type="range"
123
+ min={1}
124
+ max={10}
125
+ value={amplify}
126
+ onChange={(e) => setAmplify(parseInt(e.target.value))}
127
+ className="h-1 w-20 appearance-none rounded-full bg-zinc-700 accent-violet-500
128
+ [&::-webkit-slider-thumb]:appearance-none
129
+ [&::-webkit-slider-thumb]:w-3
130
+ [&::-webkit-slider-thumb]:h-3
131
+ [&::-webkit-slider-thumb]:rounded-full
132
+ [&::-webkit-slider-thumb]:bg-violet-500"
133
+ />
134
+ <span className="text-[10px] tabular-nums text-zinc-500">{amplify}x</span>
135
+ </div>
136
+ )}
137
+ <span className="text-[10px] tabular-nums text-zinc-600">
138
+ {currentFrame + 1}/{totalFrames}
139
+ </span>
140
+ </div>
141
+ </div>
142
+
143
+ {mode === 'side-by-side' ? (
144
+ <div className="grid grid-cols-2 gap-2">
145
+ <div className="space-y-1">
146
+ <p className="text-[10px] uppercase tracking-wider text-zinc-600">Original</p>
147
+ <canvas
148
+ ref={origCanvasRef}
149
+ width={width}
150
+ height={height}
151
+ className="w-full rounded-lg border border-zinc-800"
152
+ style={{ aspectRatio: aspect }}
153
+ />
154
+ </div>
155
+ <div className="space-y-1">
156
+ <p className="text-[10px] uppercase tracking-wider text-zinc-600">Watermarked</p>
157
+ <canvas
158
+ ref={wmCanvasRef}
159
+ width={width}
160
+ height={height}
161
+ className="w-full rounded-lg border border-zinc-800"
162
+ style={{ aspectRatio: aspect }}
163
+ />
164
+ </div>
165
+ </div>
166
+ ) : (
167
+ <div className="space-y-1">
168
+ <p className="text-[10px] uppercase tracking-wider text-zinc-600">
169
+ Pixel Difference (amplified {amplify}x)
170
+ </p>
171
+ <canvas
172
+ ref={diffCanvasRef}
173
+ width={width}
174
+ height={height}
175
+ className="w-full rounded-lg border border-zinc-800"
176
+ style={{ aspectRatio: aspect }}
177
+ />
178
+ </div>
179
+ )}
180
+
181
+ {/* Frame scrubber */}
182
+ <input
183
+ type="range"
184
+ min={0}
185
+ max={totalFrames - 1}
186
+ value={currentFrame}
187
+ onChange={(e) => { setCurrentFrame(parseInt(e.target.value)); setPlaying(false); }}
188
+ className="w-full h-1 appearance-none rounded-full bg-zinc-700 accent-blue-500
189
+ [&::-webkit-slider-thumb]:appearance-none
190
+ [&::-webkit-slider-thumb]:w-3
191
+ [&::-webkit-slider-thumb]:h-3
192
+ [&::-webkit-slider-thumb]:rounded-full
193
+ [&::-webkit-slider-thumb]:bg-blue-500"
194
+ />
195
+ </div>
196
+ );
197
+ }
web/src/components/DetectPanel.tsx ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useCallback } from 'react';
2
+ import type { DetectionResult } from '@core/types.js';
3
+ import { autoDetectMultiFrame, type AutoDetectResult } from '@core/detector.js';
4
+ import { extractFrames, rgbaToY } from '../lib/video-io.js';
5
+ import ResultCard from './ResultCard.js';
6
+
7
+ export default function DetectPanel() {
8
+ const [videoUrl, setVideoUrl] = useState<string | null>(null);
9
+ const [videoName, setVideoName] = useState('');
10
+ const [key, setKey] = useState('');
11
+ const [maxFrames, setMaxFrames] = useState(10);
12
+ const [processing, setProcessing] = useState(false);
13
+ const [progress, setProgress] = useState({ phase: '', current: 0, total: 0 });
14
+ const [result, setResult] = useState<AutoDetectResult | null>(null);
15
+ const fileRef = useRef<HTMLInputElement>(null);
16
+
17
+ const handleFile = useCallback((file: File) => {
18
+ const url = URL.createObjectURL(file);
19
+ setVideoUrl(url);
20
+ setVideoName(file.name);
21
+ setResult(null);
22
+ }, []);
23
+
24
+ const handleDrop = useCallback(
25
+ (e: React.DragEvent) => {
26
+ e.preventDefault();
27
+ const file = e.dataTransfer.files[0];
28
+ if (file?.type.startsWith('video/')) handleFile(file);
29
+ },
30
+ [handleFile]
31
+ );
32
+
33
+ const handleDetect = async () => {
34
+ if (!videoUrl || !key) return;
35
+ setProcessing(true);
36
+ setResult(null);
37
+
38
+ try {
39
+ setProgress({ phase: 'Extracting frames', current: 0, total: 0 });
40
+ const { frames, width, height } = await extractFrames(videoUrl, maxFrames, (c, t) =>
41
+ setProgress({ phase: 'Extracting frames', current: c, total: t })
42
+ );
43
+
44
+ setProgress({ phase: 'Converting frames', current: 0, total: frames.length });
45
+ const yPlanes = frames.map((frame, i) => {
46
+ setProgress({ phase: 'Converting frames', current: i + 1, total: frames.length });
47
+ return rgbaToY(frame);
48
+ });
49
+
50
+ setProgress({ phase: 'Trying all presets', current: 0, total: 0 });
51
+ const detection = autoDetectMultiFrame(yPlanes, width, height, key);
52
+
53
+ setResult(detection);
54
+ } catch (e) {
55
+ console.error('Detection error:', e);
56
+ alert(`Error: ${e}`);
57
+ } finally {
58
+ setProcessing(false);
59
+ }
60
+ };
61
+
62
+ return (
63
+ <div className="space-y-8">
64
+ {/* Upload area */}
65
+ <div
66
+ onDrop={handleDrop}
67
+ onDragOver={(e) => e.preventDefault()}
68
+ onClick={() => fileRef.current?.click()}
69
+ className={`group cursor-pointer rounded-xl border-2 border-dashed p-10 text-center transition-colors
70
+ ${videoUrl
71
+ ? 'border-zinc-700 bg-zinc-900/30'
72
+ : 'border-zinc-800 bg-zinc-900/20 hover:border-zinc-600 hover:bg-zinc-900/40'
73
+ }`}
74
+ >
75
+ <input
76
+ ref={fileRef}
77
+ type="file"
78
+ accept="video/*"
79
+ className="hidden"
80
+ onChange={(e) => {
81
+ const file = e.target.files?.[0];
82
+ if (file) handleFile(file);
83
+ }}
84
+ />
85
+ {videoUrl ? (
86
+ <div className="space-y-2">
87
+ <p className="text-sm font-medium text-zinc-300">{videoName}</p>
88
+ <p className="text-xs text-zinc-500">Click or drop to replace</p>
89
+ </div>
90
+ ) : (
91
+ <div className="space-y-2">
92
+ <svg className="mx-auto h-8 w-8 text-zinc-600 transition-colors group-hover:text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
93
+ <path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
94
+ </svg>
95
+ <p className="text-sm text-zinc-400">Drop a video file to analyze</p>
96
+ <p className="text-xs text-zinc-600">Upload a potentially watermarked video</p>
97
+ </div>
98
+ )}
99
+ </div>
100
+
101
+ {/* Configuration — just key and frame count */}
102
+ <div className="grid gap-6 sm:grid-cols-2">
103
+ <div className="space-y-1.5">
104
+ <label className="text-sm font-medium text-zinc-300">Secret Key</label>
105
+ <input
106
+ type="text"
107
+ value={key}
108
+ onChange={(e) => setKey(e.target.value)}
109
+ placeholder="Enter the secret key used for embedding..."
110
+ className="w-full rounded-lg border border-zinc-800 bg-zinc-900/50 px-3 py-2 text-sm text-zinc-100
111
+ placeholder:text-zinc-600 focus:border-blue-600 focus:outline-none focus:ring-1 focus:ring-blue-600"
112
+ />
113
+ </div>
114
+
115
+ <div className="space-y-1.5">
116
+ <label className="text-sm font-medium text-zinc-300">Frames to analyze</label>
117
+ <input
118
+ type="number"
119
+ value={maxFrames}
120
+ onChange={(e) => setMaxFrames(Math.max(1, Math.min(100, parseInt(e.target.value) || 10)))}
121
+ min={1}
122
+ max={100}
123
+ className="w-full rounded-lg border border-zinc-800 bg-zinc-900/50 px-3 py-2 text-sm text-zinc-100
124
+ focus:border-blue-600 focus:outline-none focus:ring-1 focus:ring-blue-600"
125
+ />
126
+ <p className="text-[10px] text-zinc-600">More frames = better detection, slower processing</p>
127
+ </div>
128
+ </div>
129
+
130
+ <p className="text-xs text-zinc-500">
131
+ All presets will be tried automatically. No need to know which preset was used during embedding.
132
+ </p>
133
+
134
+ {/* Detect button */}
135
+ <button
136
+ onClick={handleDetect}
137
+ disabled={!videoUrl || !key || processing}
138
+ className="w-full rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white
139
+ transition-colors hover:bg-violet-500 disabled:cursor-not-allowed disabled:bg-zinc-800 disabled:text-zinc-500"
140
+ >
141
+ {processing ? (
142
+ <span className="flex items-center justify-center gap-2">
143
+ <span className="h-4 w-4 animate-spin rounded-full border-2 border-zinc-400 border-t-white" />
144
+ {progress.phase} {progress.total > 0 ? `${progress.current}/${progress.total}` : ''}
145
+ </span>
146
+ ) : (
147
+ 'Detect Watermark'
148
+ )}
149
+ </button>
150
+
151
+ {/* Results */}
152
+ <ResultCard result={result} presetUsed={result?.presetUsed ?? null} loading={processing} />
153
+ </div>
154
+ );
155
+ }
web/src/components/EmbedPanel.tsx ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useCallback, useEffect } from 'react';
2
+ import type { PresetName } from '@core/types.js';
3
+ import { getPreset, PRESET_DESCRIPTIONS } from '@core/presets.js';
4
+ import { streamExtractAndEmbed } from '../lib/video-io.js';
5
+ import StrengthSlider from './StrengthSlider.js';
6
+ import ComparisonView from './ComparisonView.js';
7
+ import RobustnessTest from './RobustnessTest.js';
8
+
9
+ const PLEASE_HOLD = [
10
+ { art: ' (•_•)\n ( •_•)>⌐■-■\n (⌐■_■)', caption: 'Putting on invisibility shades...' },
11
+ { art: ' ┌(▀Ĺ̯▀)┐\n ┌(▀Ĺ̯▀)┘\n └(▀Ĺ̯▀)┐', caption: 'Robot dance while we wait...' },
12
+ { art: ' ╔══╗\n ║▓▓║ ☕\n ╚══╝', caption: 'Brewing the perfect watermark...' },
13
+ { art: ' 🎬 → 🔬 → 💎', caption: 'Turning pixels into secrets...' },
14
+ { art: ' [▓▓▓░░░░░░░]\n [▓▓▓▓▓░░░░░]\n [▓▓▓▓▓▓▓▓░░]', caption: 'Hiding bits in plain sight...' },
15
+ { art: ' /\\_/\\\n ( o.o )\n > ^ <', caption: 'Even the cat can\'t see the watermark...' },
16
+ { art: ' ┌─────────┐\n │ 01101001 │\n └─────────┘', caption: 'Whispering bits into wavelets...' },
17
+ { art: ' ~~ 🌊 ~~\n ~🏄~ ~~ \n ~~~~~~~~', caption: 'Surfing the frequency domain...' },
18
+ { art: ' 📼 ➜ 🧬 ➜ 📼', caption: 'Splicing invisible DNA into frames...' },
19
+ { art: ' ¯\\_(ツ)_/¯', caption: 'Trust us, the watermark is there...' },
20
+ ];
21
+
22
+ /** Max frames to keep in memory for the comparison view */
23
+ const COMPARISON_SAMPLE = 30;
24
+
25
+ export default function EmbedPanel() {
26
+ const [videoUrl, setVideoUrl] = useState<string | null>(null);
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);
35
+ const [result, setResult] = useState<{
36
+ blob: Blob;
37
+ psnr: number;
38
+ frames: number;
39
+ originalFrames: ImageData[];
40
+ watermarkedFrames: ImageData[];
41
+ width: number;
42
+ height: number;
43
+ fps: number;
44
+ } | null>(null);
45
+ const fileRef = useRef<HTMLInputElement>(null);
46
+
47
+ // Rotate jokes every 4s while processing
48
+ useEffect(() => {
49
+ if (!processing) return;
50
+ setJokeIndex(Math.floor(Math.random() * PLEASE_HOLD.length));
51
+ const id = setInterval(() => {
52
+ setJokeIndex((i) => (i + 1) % PLEASE_HOLD.length);
53
+ }, 4000);
54
+ return () => clearInterval(id);
55
+ }, [processing]);
56
+
57
+ const handleFile = useCallback((file: File) => {
58
+ const url = URL.createObjectURL(file);
59
+ setVideoUrl(url);
60
+ setVideoName(file.name);
61
+ setResult(null);
62
+ }, [result]);
63
+
64
+ const handleDrop = useCallback(
65
+ (e: React.DragEvent) => {
66
+ e.preventDefault();
67
+ const file = e.dataTransfer.files[0];
68
+ if (file?.type.startsWith('video/')) handleFile(file);
69
+ },
70
+ [handleFile]
71
+ );
72
+
73
+ const handleEmbed = async () => {
74
+ if (!videoUrl || !key) return;
75
+ setProcessing(true);
76
+ setResult(null);
77
+
78
+ try {
79
+ // Parse payload
80
+ const payloadHex = payload.replace(/^0x/, '');
81
+ const payloadBytes = new Uint8Array(
82
+ (payloadHex.length % 2 ? '0' + payloadHex : payloadHex)
83
+ .match(/.{2}/g)!
84
+ .map((b) => parseInt(b, 16))
85
+ );
86
+
87
+ const config = getPreset(preset);
88
+
89
+ // Stream: extract -> watermark -> encode in chunks
90
+ const embedResult = await streamExtractAndEmbed(
91
+ videoUrl,
92
+ payloadBytes,
93
+ key,
94
+ config,
95
+ COMPARISON_SAMPLE,
96
+ (phase, current, total) => setProgress({ phase, current, total })
97
+ );
98
+
99
+ setResult({
100
+ blob: embedResult.blob,
101
+ psnr: embedResult.avgPsnr,
102
+ frames: embedResult.totalFrames,
103
+ originalFrames: embedResult.sampleOriginal,
104
+ watermarkedFrames: embedResult.sampleWatermarked,
105
+ width: embedResult.width,
106
+ height: embedResult.height,
107
+ fps: embedResult.fps,
108
+ });
109
+ } catch (e) {
110
+ console.error('Embed error:', e);
111
+ alert(`Error: ${e}`);
112
+ } finally {
113
+ setProcessing(false);
114
+ }
115
+ };
116
+
117
+ const handleDownload = () => {
118
+ if (!result) return;
119
+ const url = URL.createObjectURL(result.blob);
120
+ const a = document.createElement('a');
121
+ a.href = url;
122
+ a.download = videoName.replace(/\.[^.]+$/, '') + '_watermarked.mp4';
123
+ a.click();
124
+ URL.revokeObjectURL(url);
125
+ };
126
+
127
+ const maxPayloadHexChars = 8; // Always 32 bits = 8 hex chars
128
+
129
+ return (
130
+ <div className="space-y-8">
131
+ {/* Upload area */}
132
+ <div
133
+ onDrop={handleDrop}
134
+ onDragOver={(e) => e.preventDefault()}
135
+ onClick={() => fileRef.current?.click()}
136
+ className={`group cursor-pointer rounded-2xl border-2 border-dashed p-10 text-center transition-all duration-200
137
+ ${videoUrl
138
+ ? 'border-zinc-700 bg-zinc-900/30'
139
+ : 'border-zinc-800 bg-zinc-900/20 hover:border-blue-600/40 hover:bg-zinc-900/40 hover:shadow-lg hover:shadow-blue-600/5'
140
+ }`}
141
+ >
142
+ <input
143
+ ref={fileRef}
144
+ type="file"
145
+ accept="video/*"
146
+ className="hidden"
147
+ onChange={(e) => {
148
+ const file = e.target.files?.[0];
149
+ if (file) handleFile(file);
150
+ }}
151
+ />
152
+ {videoUrl ? (
153
+ <div className="space-y-2">
154
+ <p className="text-sm font-medium text-zinc-300">🎬 {videoName}</p>
155
+ <p className="text-xs text-zinc-500">Click or drop to replace</p>
156
+ </div>
157
+ ) : (
158
+ <div className="space-y-3">
159
+ <div className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-zinc-800/80 transition-colors group-hover:bg-blue-600/10">
160
+ <svg className="h-6 w-6 text-zinc-500 transition-colors group-hover:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
161
+ <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
162
+ </svg>
163
+ </div>
164
+ <p className="text-sm text-zinc-400">Drop a video file or click to browse</p>
165
+ <p className="text-xs text-zinc-600">MP4, WebM, MOV supported</p>
166
+ </div>
167
+ )}
168
+ </div>
169
+
170
+ {/* Configuration */}
171
+ <div className="grid gap-6 sm:grid-cols-2">
172
+ <div className="space-y-1.5">
173
+ <label className="text-sm font-medium text-zinc-300">🔑 Secret Key</label>
174
+ <input
175
+ type="text"
176
+ value={key}
177
+ onChange={(e) => setKey(e.target.value)}
178
+ placeholder="Enter a secret key..."
179
+ className="w-full rounded-lg border border-zinc-800 bg-zinc-900/50 px-3 py-2 text-sm text-zinc-100
180
+ placeholder:text-zinc-600 focus:border-blue-600 focus:outline-none focus:ring-1 focus:ring-blue-600
181
+ transition-colors"
182
+ />
183
+ </div>
184
+
185
+ <div className="space-y-1.5">
186
+ <label className="text-sm font-medium text-zinc-300">📦 Payload (hex)</label>
187
+ <input
188
+ type="text"
189
+ value={payload}
190
+ onChange={(e) => setPayload(e.target.value.replace(/[^0-9a-fA-F]/g, '').slice(0, maxPayloadHexChars))}
191
+ placeholder="DEADBEEF"
192
+ className="w-full rounded-lg border border-zinc-800 bg-zinc-900/50 px-3 py-2 font-mono text-sm text-zinc-100
193
+ placeholder:text-zinc-600 focus:border-blue-600 focus:outline-none focus:ring-1 focus:ring-blue-600
194
+ transition-colors"
195
+ />
196
+ <p className="text-[10px] text-zinc-600">32-bit payload, 4 bytes hex</p>
197
+ </div>
198
+ </div>
199
+
200
+ <StrengthSlider
201
+ value={alpha}
202
+ onChange={(v, p) => {
203
+ setAlpha(v);
204
+ setPreset(p);
205
+ }}
206
+ disabled={processing}
207
+ />
208
+
209
+ <p className="text-xs text-zinc-500">
210
+ {PRESET_DESCRIPTIONS[preset]}
211
+ </p>
212
+
213
+ {/* Embed button */}
214
+ <button
215
+ onClick={handleEmbed}
216
+ disabled={!videoUrl || !key || processing}
217
+ className="w-full rounded-xl bg-gradient-to-r from-blue-600 to-blue-500 px-4 py-3 text-sm font-semibold text-white
218
+ shadow-lg shadow-blue-600/20 transition-all hover:from-blue-500 hover:to-blue-400 hover:shadow-blue-500/30
219
+ disabled:cursor-not-allowed disabled:from-zinc-800 disabled:to-zinc-800 disabled:text-zinc-500 disabled:shadow-none"
220
+ >
221
+ {processing ? (
222
+ <span className="flex items-center justify-center gap-2">
223
+ <span className="h-4 w-4 animate-spin rounded-full border-2 border-zinc-400 border-t-white" />
224
+ {progress.phase} {progress.total > 0 ? `${progress.current}/${progress.total}` : ''}
225
+ </span>
226
+ ) : (
227
+ '✨ Embed Watermark'
228
+ )}
229
+ </button>
230
+
231
+ {/* Please-hold jokes while processing */}
232
+ {processing && (
233
+ <div className="flex flex-col items-center gap-2 rounded-xl bg-zinc-900/50 py-6 text-center">
234
+ <pre className="font-mono text-sm leading-tight text-zinc-400">
235
+ {PLEASE_HOLD[jokeIndex].art}
236
+ </pre>
237
+ <p className="text-xs text-zinc-500">{PLEASE_HOLD[jokeIndex].caption}</p>
238
+ </div>
239
+ )}
240
+
241
+ {/* Results */}
242
+ {result && (
243
+ <div className="space-y-6 rounded-2xl border border-emerald-800/30 bg-emerald-950/10 p-6">
244
+ <div className="flex items-center justify-between">
245
+ <div>
246
+ <h3 className="text-sm font-semibold text-emerald-400">✅ Embedding Complete</h3>
247
+ <p className="mt-1 text-xs text-zinc-500">
248
+ {result.frames} frames processed �� Average PSNR: {result.psnr.toFixed(1)} dB
249
+ </p>
250
+ </div>
251
+ <button
252
+ onClick={handleDownload}
253
+ className="rounded-lg bg-emerald-600/20 px-4 py-2 text-sm font-medium text-emerald-400
254
+ ring-1 ring-emerald-600/30 transition-all hover:bg-emerald-600/30 hover:ring-emerald-500/40"
255
+ >
256
+ ⬇️ Download
257
+ </button>
258
+ </div>
259
+
260
+ <ComparisonView
261
+ originalFrames={result.originalFrames}
262
+ watermarkedFrames={result.watermarkedFrames}
263
+ width={result.width}
264
+ height={result.height}
265
+ fps={result.fps}
266
+ />
267
+
268
+ <RobustnessTest
269
+ blob={result.blob}
270
+ width={result.width}
271
+ height={result.height}
272
+ payload={payload}
273
+ secretKey={key}
274
+ />
275
+ </div>
276
+ )}
277
+ </div>
278
+ );
279
+ }
web/src/components/ResultCard.tsx ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { DetectionResult, PresetName } from '@core/types.js';
2
+
3
+ interface ResultCardProps {
4
+ result: DetectionResult | null;
5
+ presetUsed?: PresetName | null;
6
+ loading?: boolean;
7
+ }
8
+
9
+ export default function ResultCard({ result, presetUsed, loading }: ResultCardProps) {
10
+ if (loading) {
11
+ return (
12
+ <div className="rounded-xl border border-zinc-800/50 bg-zinc-900/50 p-6">
13
+ <div className="flex items-center gap-3">
14
+ <div className="h-5 w-5 animate-spin rounded-full border-2 border-zinc-600 border-t-blue-500" />
15
+ <span className="text-sm text-zinc-400">Analyzing frames...</span>
16
+ </div>
17
+ </div>
18
+ );
19
+ }
20
+
21
+ if (!result) return null;
22
+
23
+ const payloadHex = result.payload
24
+ ? Array.from(result.payload).map((b) => b.toString(16).padStart(2, '0')).join('').toUpperCase()
25
+ : '—';
26
+
27
+ return (
28
+ <div
29
+ className={`rounded-xl border p-6 transition-colors ${
30
+ result.detected
31
+ ? 'border-emerald-800/50 bg-emerald-950/30'
32
+ : 'border-amber-800/50 bg-amber-950/20'
33
+ }`}
34
+ >
35
+ <div className="flex items-start justify-between">
36
+ <div>
37
+ <h3 className={`text-sm font-semibold ${result.detected ? 'text-emerald-400' : 'text-amber-400'}`}>
38
+ {result.detected ? 'Watermark Detected' : 'No Watermark Found'}
39
+ </h3>
40
+
41
+ {result.detected && (
42
+ <div className="mt-4 space-y-3">
43
+ <div>
44
+ <p className="text-[10px] uppercase tracking-wider text-zinc-500">Payload</p>
45
+ <p className="mt-0.5 font-mono text-lg tracking-wide text-zinc-100">{payloadHex}</p>
46
+ </div>
47
+
48
+ <div className="flex gap-6">
49
+ <div>
50
+ <p className="text-[10px] uppercase tracking-wider text-zinc-500">Confidence</p>
51
+ <div className="mt-1 flex items-center gap-2">
52
+ <div className="h-1.5 w-24 overflow-hidden rounded-full bg-zinc-800">
53
+ <div
54
+ className="h-full rounded-full bg-emerald-500 transition-all"
55
+ style={{ width: `${result.confidence * 100}%` }}
56
+ />
57
+ </div>
58
+ <span className="text-xs tabular-nums text-zinc-400">
59
+ {(result.confidence * 100).toFixed(1)}%
60
+ </span>
61
+ </div>
62
+ </div>
63
+
64
+ <div>
65
+ <p className="text-[10px] uppercase tracking-wider text-zinc-500">Tiles used</p>
66
+ <p className="mt-0.5 text-sm tabular-nums text-zinc-300">
67
+ {result.tilesDecoded} of {result.tilesTotal} available
68
+ </p>
69
+ </div>
70
+
71
+ {presetUsed && (
72
+ <div>
73
+ <p className="text-[10px] uppercase tracking-wider text-zinc-500">Preset</p>
74
+ <p className="mt-0.5 text-sm capitalize text-zinc-300">
75
+ {presetUsed}
76
+ </p>
77
+ </div>
78
+ )}
79
+ </div>
80
+ </div>
81
+ )}
82
+ </div>
83
+
84
+ <div
85
+ className={`flex h-10 w-10 items-center justify-center rounded-full ${
86
+ result.detected ? 'bg-emerald-500/10' : 'bg-amber-500/10'
87
+ }`}
88
+ >
89
+ {result.detected ? (
90
+ <svg className="h-5 w-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
91
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
92
+ </svg>
93
+ ) : (
94
+ <svg className="h-5 w-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
95
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
96
+ </svg>
97
+ )}
98
+ </div>
99
+ </div>
100
+ </div>
101
+ );
102
+ }
web/src/components/RobustnessTest.tsx ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback } from 'react';
2
+ import { autoDetectMultiFrame } from '@core/detector.js';
3
+ import { attackReencode, attackDownscale, attackBrightness, attackContrast, attackSaturation } from '../lib/video-io.js';
4
+
5
+ type TestStatus = 'idle' | 'running' | 'pass' | 'fail' | 'error';
6
+
7
+ interface TestResult {
8
+ status: TestStatus;
9
+ confidence?: number;
10
+ payloadMatch?: boolean;
11
+ }
12
+
13
+ interface RobustnessTestProps {
14
+ blob: Blob;
15
+ width: number;
16
+ height: number;
17
+ payload: string;
18
+ secretKey: string;
19
+ }
20
+
21
+ interface TestDef {
22
+ label: string;
23
+ category: string;
24
+ run: (blob: Blob, w: number, h: number) => Promise<{ yPlanes: Uint8Array[]; width: number; height: number }>;
25
+ }
26
+
27
+ const TESTS: TestDef[] = [
28
+ // CRF re-encoding
29
+ { label: 'CRF 23', category: 'Re-encode', run: (b, w, h) => attackReencode(b, 23, w, h) },
30
+ { label: 'CRF 28', category: 'Re-encode', run: (b, w, h) => attackReencode(b, 28, w, h) },
31
+ { label: 'CRF 33', category: 'Re-encode', run: (b, w, h) => attackReencode(b, 33, w, h) },
32
+ { label: 'CRF 38', category: 'Re-encode', run: (b, w, h) => attackReencode(b, 38, w, h) },
33
+ { label: 'CRF 43', category: 'Re-encode', run: (b, w, h) => attackReencode(b, 43, w, h) },
34
+ // Downscale
35
+ { label: '50%', category: 'Downscale', run: (b, w, h) => attackDownscale(b, 50, w, h) },
36
+ { label: '75%', category: 'Downscale', run: (b, w, h) => attackDownscale(b, 75, w, h) },
37
+ { label: '90%', category: 'Downscale', run: (b, w, h) => attackDownscale(b, 90, w, h) },
38
+ // Brightness
39
+ { label: '-0.2', category: 'Brightness', run: (b, w, h) => attackBrightness(b, -0.2, w, h) },
40
+ { label: '+0.2', category: 'Brightness', run: (b, w, h) => attackBrightness(b, 0.2, w, h) },
41
+ { label: '+0.4', category: 'Brightness', run: (b, w, h) => attackBrightness(b, 0.4, w, h) },
42
+ // Contrast
43
+ { label: '0.5x', category: 'Contrast', run: (b, w, h) => attackContrast(b, 0.5, w, h) },
44
+ { label: '1.5x', category: 'Contrast', run: (b, w, h) => attackContrast(b, 1.5, w, h) },
45
+ { label: '2.0x', category: 'Contrast', run: (b, w, h) => attackContrast(b, 2.0, w, h) },
46
+ // Saturation
47
+ { label: '0x', category: 'Saturation', run: (b, w, h) => attackSaturation(b, 0, w, h) },
48
+ { label: '0.5x', category: 'Saturation', run: (b, w, h) => attackSaturation(b, 0.5, w, h) },
49
+ { label: '2.0x', category: 'Saturation', run: (b, w, h) => attackSaturation(b, 2.0, w, h) },
50
+ ];
51
+
52
+ function payloadToHex(payload: Uint8Array): string {
53
+ return Array.from(payload).map((b) => b.toString(16).padStart(2, '0')).join('').toUpperCase();
54
+ }
55
+
56
+ export default function RobustnessTest({ blob, width, height, payload, secretKey }: RobustnessTestProps) {
57
+ const [results, setResults] = useState<Record<number, TestResult>>({});
58
+ const [runningAll, setRunningAll] = useState(false);
59
+
60
+ const expectedHex = payload.replace(/^0x/, '').toUpperCase();
61
+
62
+ const runTest = useCallback(async (idx: number) => {
63
+ setResults((prev) => ({ ...prev, [idx]: { status: 'running' } }));
64
+ try {
65
+ const test = TESTS[idx];
66
+ const attacked = await test.run(blob, width, height);
67
+
68
+ // Pipeline already caps at 30 frames; use up to 10 evenly spaced
69
+ const step = Math.max(1, Math.floor(attacked.yPlanes.length / 10));
70
+ const sampled = attacked.yPlanes.filter((_, i) => i % step === 0).slice(0, 10);
71
+
72
+ const detection = autoDetectMultiFrame(sampled, attacked.width, attacked.height, secretKey);
73
+
74
+ const detectedHex = detection.payload ? payloadToHex(detection.payload) : '';
75
+ const match = detection.detected && detectedHex === expectedHex;
76
+
77
+ setResults((prev) => ({
78
+ ...prev,
79
+ [idx]: {
80
+ status: match ? 'pass' : 'fail',
81
+ confidence: detection.confidence,
82
+ payloadMatch: match,
83
+ },
84
+ }));
85
+ } catch (e) {
86
+ console.error(`Robustness test ${idx} error:`, e);
87
+ setResults((prev) => ({ ...prev, [idx]: { status: 'error' } }));
88
+ }
89
+ }, [blob, width, height, secretKey, expectedHex]);
90
+
91
+ const runAll = useCallback(async () => {
92
+ setRunningAll(true);
93
+ for (let i = 0; i < TESTS.length; i++) {
94
+ await runTest(i);
95
+ }
96
+ setRunningAll(false);
97
+ }, [runTest]);
98
+
99
+ const categories = ['Re-encode', 'Downscale', 'Brightness', 'Contrast', 'Saturation'];
100
+
101
+ return (
102
+ <div className="space-y-4">
103
+ <div className="flex items-center justify-between">
104
+ <h4 className="text-sm font-semibold text-zinc-300">Robustness Testing</h4>
105
+ <button
106
+ onClick={runAll}
107
+ disabled={runningAll}
108
+ className="rounded-lg bg-zinc-800 px-3 py-1.5 text-xs font-medium text-zinc-300
109
+ ring-1 ring-zinc-700 transition-all hover:bg-zinc-700 hover:text-zinc-100
110
+ disabled:cursor-not-allowed disabled:opacity-50"
111
+ >
112
+ {runningAll ? (
113
+ <span className="flex items-center gap-1.5">
114
+ <span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-zinc-500 border-t-zinc-200" />
115
+ Running...
116
+ </span>
117
+ ) : (
118
+ 'Run All Tests'
119
+ )}
120
+ </button>
121
+ </div>
122
+
123
+ <div className="space-y-3">
124
+ {categories.map((cat) => {
125
+ const catTests = TESTS.map((t, i) => ({ ...t, idx: i })).filter((t) => t.category === cat);
126
+ return (
127
+ <div key={cat} className="flex items-center gap-2">
128
+ <span className="w-24 shrink-0 text-xs text-zinc-500">{cat}</span>
129
+ <div className="flex flex-wrap gap-1.5">
130
+ {catTests.map(({ label, idx }) => {
131
+ const r = results[idx];
132
+ const status = r?.status ?? 'idle';
133
+ return (
134
+ <button
135
+ key={idx}
136
+ onClick={() => runTest(idx)}
137
+ disabled={status === 'running' || runningAll}
138
+ className={`inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium
139
+ ring-1 transition-all disabled:cursor-not-allowed
140
+ ${status === 'idle' ? 'bg-zinc-800/80 text-zinc-400 ring-zinc-700 hover:bg-zinc-700 hover:text-zinc-200' : ''}
141
+ ${status === 'running' ? 'bg-zinc-800 text-zinc-400 ring-zinc-700' : ''}
142
+ ${status === 'pass' ? 'bg-emerald-950/40 text-emerald-400 ring-emerald-800/50' : ''}
143
+ ${status === 'fail' ? 'bg-red-950/40 text-red-400 ring-red-800/50' : ''}
144
+ ${status === 'error' ? 'bg-amber-950/40 text-amber-400 ring-amber-800/50' : ''}
145
+ `}
146
+ >
147
+ {status === 'running' && (
148
+ <span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-zinc-500 border-t-zinc-200" />
149
+ )}
150
+ {status === 'pass' && <span>&#x2705;</span>}
151
+ {status === 'fail' && <span>&#x274C;</span>}
152
+ {status === 'error' && <span>&#x26A0;&#xFE0F;</span>}
153
+ {label}
154
+ {r?.confidence !== undefined && (
155
+ <span className="ml-0.5 opacity-70">{(r.confidence * 100).toFixed(0)}%</span>
156
+ )}
157
+ </button>
158
+ );
159
+ })}
160
+ </div>
161
+ </div>
162
+ );
163
+ })}
164
+ </div>
165
+ </div>
166
+ );
167
+ }
web/src/components/StrengthSlider.tsx ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { PresetName } from '@core/types.js';
2
+
3
+ const PRESET_MARKS: Array<{ value: number; label: string; emoji: string; preset: PresetName }> = [
4
+ { value: 0.00, label: 'Light', emoji: '🌤️', preset: 'light' },
5
+ { value: 0.33, label: 'Moderate', emoji: '⚡', preset: 'moderate' },
6
+ { value: 0.67, label: 'Strong', emoji: '🛡️', preset: 'strong' },
7
+ { value: 1.00, label: 'Fortress', emoji: '🏰', preset: 'fortress' },
8
+ ];
9
+
10
+ interface StrengthSliderProps {
11
+ value: number;
12
+ onChange: (value: number, preset: PresetName) => void;
13
+ disabled?: boolean;
14
+ }
15
+
16
+ export default function StrengthSlider({ value, onChange, disabled }: StrengthSliderProps) {
17
+ const nearestPreset = PRESET_MARKS.reduce((best, mark) =>
18
+ Math.abs(mark.value - value) < Math.abs(best.value - value) ? mark : best
19
+ );
20
+
21
+ return (
22
+ <div className="space-y-3">
23
+ <div className="flex items-center justify-between">
24
+ <label className="text-sm font-medium text-zinc-300">Strength</label>
25
+ <span className="text-xs tabular-nums text-zinc-500">
26
+ {nearestPreset.emoji} {nearestPreset.label}
27
+ </span>
28
+ </div>
29
+
30
+ <div className="relative">
31
+ <input
32
+ type="range"
33
+ min="0"
34
+ max="1"
35
+ step="0.01"
36
+ value={value}
37
+ disabled={disabled}
38
+ onChange={(e) => {
39
+ const v = parseFloat(e.target.value);
40
+ const snap = PRESET_MARKS.find((m) => Math.abs(m.value - v) < 0.04);
41
+ if (snap) {
42
+ onChange(snap.value, snap.preset);
43
+ } else {
44
+ // Snap to nearest preset (no custom mode)
45
+ const nearest = PRESET_MARKS.reduce((best, mark) =>
46
+ Math.abs(mark.value - v) < Math.abs(best.value - v) ? mark : best
47
+ );
48
+ onChange(nearest.value, nearest.preset);
49
+ }
50
+ }}
51
+ className="w-full h-1.5 rounded-full appearance-none cursor-pointer
52
+ bg-zinc-700 accent-blue-500
53
+ disabled:opacity-50 disabled:cursor-not-allowed
54
+ [&::-webkit-slider-thumb]:appearance-none
55
+ [&::-webkit-slider-thumb]:w-4
56
+ [&::-webkit-slider-thumb]:h-4
57
+ [&::-webkit-slider-thumb]:rounded-full
58
+ [&::-webkit-slider-thumb]:bg-blue-500
59
+ [&::-webkit-slider-thumb]:shadow-lg
60
+ [&::-webkit-slider-thumb]:shadow-blue-500/20
61
+ [&::-webkit-slider-thumb]:transition-transform
62
+ [&::-webkit-slider-thumb]:hover:scale-110"
63
+ />
64
+
65
+ <div className="relative mt-2 h-5">
66
+ {PRESET_MARKS.map((mark, i) => {
67
+ const pct = mark.value * 100;
68
+ const isFirst = i === 0;
69
+ const isLast = i === PRESET_MARKS.length - 1;
70
+ const align = isFirst ? 'left-0' : isLast ? 'right-0' : '-translate-x-1/2';
71
+ return (
72
+ <button
73
+ key={mark.preset}
74
+ onClick={() => onChange(mark.value, mark.preset)}
75
+ disabled={disabled}
76
+ style={isFirst || isLast ? undefined : { left: `${pct}%` }}
77
+ className={`absolute whitespace-nowrap text-[10px] transition-colors ${align} ${
78
+ nearestPreset.preset === mark.preset
79
+ ? 'text-blue-400 font-medium'
80
+ : 'text-zinc-600 hover:text-zinc-400'
81
+ } disabled:opacity-50`}
82
+ >
83
+ {mark.emoji} {mark.label}
84
+ </button>
85
+ );
86
+ })}
87
+ </div>
88
+ </div>
89
+ </div>
90
+ );
91
+ }
web/src/index.css ADDED
@@ -0,0 +1 @@
 
 
1
+ @import "tailwindcss";
web/src/lib/video-io.ts ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Browser-based video frame extraction and re-encoding
3
+ * Uses ffmpeg.wasm for H.264 MP4 output
4
+ */
5
+
6
+ import { FFmpeg } from '@ffmpeg/ffmpeg';
7
+ import { toBlobURL } from '@ffmpeg/util';
8
+ import type { WatermarkConfig } from '@core/types.js';
9
+ import { embedWatermark } from '@core/embedder.js';
10
+
11
+ let ffmpegInstance: FFmpeg | null = null;
12
+ let ffmpegLoaded = false;
13
+
14
+ /** Get or initialize the shared FFmpeg instance */
15
+ async function getFFmpeg(onLog?: (msg: string) => void): Promise<FFmpeg> {
16
+ if (ffmpegInstance && ffmpegLoaded) return ffmpegInstance;
17
+
18
+ ffmpegInstance = new FFmpeg();
19
+ if (onLog) {
20
+ ffmpegInstance.on('log', ({ message }) => onLog(message));
21
+ }
22
+
23
+ // Load ffmpeg.wasm from CDN
24
+ const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
25
+ await ffmpegInstance.load({
26
+ coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
27
+ wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
28
+ });
29
+
30
+ ffmpegLoaded = true;
31
+ return ffmpegInstance;
32
+ }
33
+
34
+ /** Extract Y plane from an RGBA ImageData */
35
+ export function rgbaToY(imageData: ImageData): Uint8Array {
36
+ const { data, width, height } = imageData;
37
+ const y = new Uint8Array(width * height);
38
+ for (let i = 0; i < width * height; i++) {
39
+ const r = data[i * 4];
40
+ const g = data[i * 4 + 1];
41
+ const b = data[i * 4 + 2];
42
+ y[i] = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
43
+ }
44
+ return y;
45
+ }
46
+
47
+ /** Apply Y plane delta to RGBA ImageData (modifies in place) */
48
+ export function applyYDelta(imageData: ImageData, originalY: Uint8Array, watermarkedY: Uint8Array): void {
49
+ const { data } = imageData;
50
+ for (let i = 0; i < originalY.length; i++) {
51
+ const delta = watermarkedY[i] - originalY[i];
52
+ data[i * 4] = Math.max(0, Math.min(255, data[i * 4] + delta));
53
+ data[i * 4 + 1] = Math.max(0, Math.min(255, data[i * 4 + 1] + delta));
54
+ data[i * 4 + 2] = Math.max(0, Math.min(255, data[i * 4 + 2] + delta));
55
+ }
56
+ }
57
+
58
+ /** Result of the streaming extract+embed pipeline */
59
+ export interface StreamEmbedResult {
60
+ blob: Blob;
61
+ width: number;
62
+ height: number;
63
+ fps: number;
64
+ totalFrames: number;
65
+ avgPsnr: number;
66
+ sampleOriginal: ImageData[];
67
+ sampleWatermarked: ImageData[];
68
+ }
69
+
70
+ /** Number of frames to accumulate before flushing to H.264 encoder */
71
+ const CHUNK_SIZE = 100;
72
+
73
+ /**
74
+ * Streaming extract → watermark → encode pipeline.
75
+ * Processes frames in chunks of CHUNK_SIZE, encoding each chunk to a
76
+ * temporary MP4 segment and freeing the raw buffer. This keeps peak
77
+ * memory at ~(CHUNK_SIZE * frameSize) instead of (totalFrames * frameSize).
78
+ * At the end, all segments are concatenated into the final MP4.
79
+ */
80
+ export async function streamExtractAndEmbed(
81
+ videoUrl: string,
82
+ payload: Uint8Array,
83
+ key: string,
84
+ config: WatermarkConfig,
85
+ comparisonSample: number,
86
+ onProgress?: (phase: string, current: number, total: number) => void
87
+ ): Promise<StreamEmbedResult> {
88
+ const video = document.createElement('video');
89
+ video.src = videoUrl;
90
+ video.muted = true;
91
+ video.preload = 'auto';
92
+
93
+ await new Promise<void>((resolve, reject) => {
94
+ video.onloadedmetadata = () => resolve();
95
+ video.onerror = () => reject(new Error('Failed to load video'));
96
+ });
97
+
98
+ const { videoWidth: rawW, videoHeight: rawH, duration } = video;
99
+ // x264 with yuv420p requires even dimensions
100
+ const width = rawW % 2 === 0 ? rawW : rawW - 1;
101
+ const height = rawH % 2 === 0 ? rawH : rawH - 1;
102
+ const totalFrames = Math.ceil(duration * 30);
103
+ const interval = duration / totalFrames;
104
+ const fps = totalFrames / duration;
105
+
106
+ const canvas = document.createElement('canvas');
107
+ canvas.width = width;
108
+ canvas.height = height;
109
+ const ctx = canvas.getContext('2d')!;
110
+
111
+ const frameSize = width * height * 4;
112
+
113
+ // Determine which frame indices to sample for comparison
114
+ const sampleIndices = new Set<number>();
115
+ const sampleStep = Math.max(1, Math.floor(totalFrames / comparisonSample));
116
+ for (let i = 0; i < totalFrames && sampleIndices.size < comparisonSample; i += sampleStep) {
117
+ sampleIndices.add(i);
118
+ }
119
+
120
+ const sampleOriginal: ImageData[] = [];
121
+ const sampleWatermarked: ImageData[] = [];
122
+ let totalPsnr = 0;
123
+
124
+ const ffmpeg = await getFFmpeg();
125
+ const segments: string[] = [];
126
+
127
+ // Chunk buffer — reused across chunks
128
+ let chunkBuffer = new Uint8Array(Math.min(CHUNK_SIZE, totalFrames) * frameSize);
129
+ let chunkOffset = 0;
130
+ let framesInChunk = 0;
131
+
132
+ const flushChunk = async () => {
133
+ if (framesInChunk === 0) return;
134
+
135
+ const segName = `seg_${segments.length}.mp4`;
136
+ const usedBytes = chunkBuffer.slice(0, framesInChunk * frameSize);
137
+ await ffmpeg.writeFile('chunk.raw', usedBytes);
138
+
139
+ await ffmpeg.exec([
140
+ '-f', 'rawvideo',
141
+ '-pix_fmt', 'rgba',
142
+ '-s', `${width}x${height}`,
143
+ '-r', String(fps),
144
+ '-i', 'chunk.raw',
145
+ '-c:v', 'libx264',
146
+ '-pix_fmt', 'yuv420p',
147
+ '-crf', '20',
148
+ '-preset', 'ultrafast',
149
+ '-an', '-y',
150
+ segName,
151
+ ]);
152
+
153
+ await ffmpeg.deleteFile('chunk.raw');
154
+ segments.push(segName);
155
+ chunkOffset = 0;
156
+ framesInChunk = 0;
157
+ };
158
+
159
+ for (let i = 0; i < totalFrames; i++) {
160
+ onProgress?.('Embedding', i + 1, totalFrames);
161
+
162
+ // Seek and extract frame
163
+ video.currentTime = i * interval;
164
+ await new Promise<void>((resolve) => {
165
+ video.onseeked = () => resolve();
166
+ });
167
+ ctx.drawImage(video, 0, 0, width, height);
168
+ const frameData = ctx.getImageData(0, 0, width, height);
169
+
170
+ // Watermark
171
+ const y = rgbaToY(frameData);
172
+ const result = embedWatermark(y, width, height, payload, key, config);
173
+ totalPsnr += result.psnr;
174
+
175
+ // Apply watermark delta to RGBA
176
+ applyYDelta(frameData, y, result.yPlane);
177
+
178
+ // Append to chunk buffer
179
+ chunkBuffer.set(frameData.data, chunkOffset);
180
+ chunkOffset += frameSize;
181
+ framesInChunk++;
182
+
183
+ // Keep sample frames for comparison
184
+ if (sampleIndices.has(i)) {
185
+ ctx.drawImage(video, 0, 0, width, height);
186
+ sampleOriginal.push(ctx.getImageData(0, 0, width, height));
187
+ sampleWatermarked.push(frameData);
188
+ }
189
+
190
+ // Flush chunk when full
191
+ if (framesInChunk >= CHUNK_SIZE) {
192
+ onProgress?.('Encoding chunk', segments.length + 1, Math.ceil(totalFrames / CHUNK_SIZE));
193
+ await flushChunk();
194
+ }
195
+ }
196
+
197
+ // Flush remaining frames
198
+ if (framesInChunk > 0) {
199
+ onProgress?.('Encoding chunk', segments.length + 1, Math.ceil(totalFrames / CHUNK_SIZE));
200
+ await flushChunk();
201
+ }
202
+
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
+
220
+ await ffmpeg.exec([
221
+ '-f', 'concat',
222
+ '-safe', '0',
223
+ '-i', 'concat.txt',
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 {
242
+ blob,
243
+ width,
244
+ height,
245
+ fps,
246
+ totalFrames,
247
+ avgPsnr: totalPsnr / totalFrames,
248
+ sampleOriginal,
249
+ sampleWatermarked,
250
+ };
251
+ }
252
+
253
+ /**
254
+ * Extract frames from a video (used by detect panel).
255
+ * Spreads frames evenly across the full duration.
256
+ */
257
+ export async function extractFrames(
258
+ videoUrl: string,
259
+ maxFrames: number = 30,
260
+ onProgress?: (frame: number, total: number) => void
261
+ ): Promise<{ frames: ImageData[]; width: number; height: number; fps: number; duration: number }> {
262
+ const video = document.createElement('video');
263
+ video.src = videoUrl;
264
+ video.muted = true;
265
+ video.preload = 'auto';
266
+
267
+ await new Promise<void>((resolve, reject) => {
268
+ video.onloadedmetadata = () => resolve();
269
+ video.onerror = () => reject(new Error('Failed to load video'));
270
+ });
271
+
272
+ const { videoWidth: rawW, videoHeight: rawH, duration } = video;
273
+ const width = rawW % 2 === 0 ? rawW : rawW - 1;
274
+ const height = rawH % 2 === 0 ? rawH : rawH - 1;
275
+ const nativeFrameCount = Math.ceil(duration * 30);
276
+ const totalFrames = Math.min(maxFrames, nativeFrameCount);
277
+ const interval = duration / totalFrames;
278
+
279
+ const canvas = document.createElement('canvas');
280
+ canvas.width = width;
281
+ canvas.height = height;
282
+ const ctx = canvas.getContext('2d')!;
283
+
284
+ const frames: ImageData[] = [];
285
+
286
+ for (let i = 0; i < totalFrames; i++) {
287
+ video.currentTime = i * interval;
288
+ await new Promise<void>((resolve) => {
289
+ video.onseeked = () => resolve();
290
+ });
291
+
292
+ ctx.drawImage(video, 0, 0, width, height);
293
+ frames.push(ctx.getImageData(0, 0, width, height));
294
+ onProgress?.(i + 1, totalFrames);
295
+ }
296
+
297
+ const fps = totalFrames / duration;
298
+
299
+ return { frames, width, height, fps, duration };
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // Attack utilities — apply degradation to watermarked MP4, return Y planes
304
+ // ---------------------------------------------------------------------------
305
+
306
+ /** Result of decoding an attacked video back to Y planes */
307
+ export interface AttackYPlanes {
308
+ yPlanes: Uint8Array[];
309
+ width: number;
310
+ height: number;
311
+ }
312
+
313
+ /**
314
+ * Write blob to ffmpeg FS, run attack filter, decode output to Y planes.
315
+ * Only processes `maxFrames` frames to keep WASM encoding fast.
316
+ */
317
+ async function runAttackPipeline(
318
+ blob: Blob,
319
+ filterArgs: string[],
320
+ outputWidth: number,
321
+ outputHeight: number,
322
+ maxFrames: number = 30,
323
+ ): Promise<AttackYPlanes> {
324
+ const ffmpeg = await getFFmpeg();
325
+
326
+ const inputData = new Uint8Array(await blob.arrayBuffer());
327
+ await ffmpeg.writeFile('attack_input.mp4', inputData);
328
+
329
+ await ffmpeg.exec([
330
+ '-i', 'attack_input.mp4',
331
+ ...filterArgs,
332
+ '-frames:v', String(maxFrames),
333
+ '-c:v', 'libx264',
334
+ '-pix_fmt', 'yuv420p',
335
+ '-preset', 'ultrafast',
336
+ '-an',
337
+ '-y',
338
+ 'attack_output.mp4',
339
+ ]);
340
+
341
+ // Decode attacked output to raw grayscale
342
+ await ffmpeg.exec([
343
+ '-i', 'attack_output.mp4',
344
+ '-f', 'rawvideo',
345
+ '-pix_fmt', 'gray',
346
+ '-y',
347
+ 'attack_raw.raw',
348
+ ]);
349
+
350
+ const rawData = await ffmpeg.readFile('attack_raw.raw');
351
+
352
+ await ffmpeg.deleteFile('attack_input.mp4');
353
+ await ffmpeg.deleteFile('attack_output.mp4');
354
+ await ffmpeg.deleteFile('attack_raw.raw');
355
+
356
+ if (typeof rawData === 'string') throw new Error('Unexpected string output from ffmpeg');
357
+ const raw = new Uint8Array((rawData as unknown as { buffer: ArrayBuffer }).buffer);
358
+
359
+ const frameSize = outputWidth * outputHeight;
360
+ const frameCount = Math.floor(raw.length / frameSize);
361
+ const yPlanes: Uint8Array[] = [];
362
+ for (let i = 0; i < frameCount; i++) {
363
+ yPlanes.push(raw.slice(i * frameSize, (i + 1) * frameSize));
364
+ }
365
+
366
+ return { yPlanes, width: outputWidth, height: outputHeight };
367
+ }
368
+
369
+ /** Attack: re-encode at a given CRF quality level */
370
+ export async function attackReencode(blob: Blob, crf: number, width: number, height: number): Promise<AttackYPlanes> {
371
+ return runAttackPipeline(blob, ['-crf', String(crf)], width, height);
372
+ }
373
+
374
+ /** Attack: downscale to scalePct%, then scale back up to original size */
375
+ export async function attackDownscale(
376
+ blob: Blob,
377
+ scalePct: number,
378
+ width: number,
379
+ height: number,
380
+ ): Promise<AttackYPlanes> {
381
+ const scaledW = Math.round(width * scalePct / 100);
382
+ const scaledH = Math.round(height * scalePct / 100);
383
+ // Ensure even dimensions for libx264
384
+ const sW = scaledW % 2 === 0 ? scaledW : scaledW + 1;
385
+ const sH = scaledH % 2 === 0 ? scaledH : scaledH + 1;
386
+ return runAttackPipeline(
387
+ blob,
388
+ ['-vf', `scale=${sW}:${sH},scale=${width}:${height}`, '-crf', '18'],
389
+ width,
390
+ height,
391
+ );
392
+ }
393
+
394
+ /** Attack: adjust brightness by delta (-1.0 to 1.0) */
395
+ export async function attackBrightness(blob: Blob, delta: number, width: number, height: number): Promise<AttackYPlanes> {
396
+ return runAttackPipeline(blob, ['-vf', `eq=brightness=${delta}`, '-crf', '18'], width, height);
397
+ }
398
+
399
+ /** Attack: adjust contrast (1.0 = unchanged, <1 = less, >1 = more) */
400
+ export async function attackContrast(blob: Blob, factor: number, width: number, height: number): Promise<AttackYPlanes> {
401
+ return runAttackPipeline(blob, ['-vf', `eq=contrast=${factor}`, '-crf', '18'], width, height);
402
+ }
403
+
404
+ /** Attack: adjust saturation (1.0 = unchanged, 0 = grayscale, >1 = boosted) */
405
+ export async function attackSaturation(blob: Blob, factor: number, width: number, height: number): Promise<AttackYPlanes> {
406
+ return runAttackPipeline(blob, ['-vf', `eq=saturation=${factor}`, '-crf', '18'], width, height);
407
+ }
408
+
web/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import App from './App';
4
+ import './index.css';
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>
10
+ );
web/src/workers/watermark.worker.ts ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Web Worker for non-blocking watermark embedding/detection
3
+ */
4
+
5
+ import { embedWatermark } from '@core/embedder.js';
6
+ import { detectWatermark } from '@core/detector.js';
7
+ import { getPreset } from '@core/presets.js';
8
+ import type { PresetName, WatermarkConfig } from '@core/types.js';
9
+
10
+ export type WorkerMessage =
11
+ | {
12
+ type: 'embed';
13
+ id: number;
14
+ yPlane: Uint8Array;
15
+ width: number;
16
+ height: number;
17
+ payload: Uint8Array;
18
+ key: string;
19
+ preset: PresetName;
20
+ customConfig?: Partial<WatermarkConfig>;
21
+ }
22
+ | {
23
+ type: 'detect';
24
+ id: number;
25
+ yPlane: Uint8Array;
26
+ width: number;
27
+ height: number;
28
+ key: string;
29
+ preset: PresetName;
30
+ customConfig?: Partial<WatermarkConfig>;
31
+ };
32
+
33
+ self.onmessage = (e: MessageEvent<WorkerMessage>) => {
34
+ const msg = e.data;
35
+
36
+ try {
37
+ const config = getPreset(msg.preset);
38
+
39
+ if (msg.type === 'embed') {
40
+ const result = embedWatermark(msg.yPlane, msg.width, msg.height, msg.payload, msg.key, config);
41
+ self.postMessage({
42
+ type: 'embed-result',
43
+ id: msg.id,
44
+ yPlane: result.yPlane,
45
+ psnr: result.psnr,
46
+ });
47
+ } else if (msg.type === 'detect') {
48
+ const result = detectWatermark(msg.yPlane, msg.width, msg.height, msg.key, config);
49
+ self.postMessage({
50
+ type: 'detect-result',
51
+ id: msg.id,
52
+ ...result,
53
+ });
54
+ }
55
+ } catch (err) {
56
+ self.postMessage({
57
+ type: 'error',
58
+ id: msg.id,
59
+ error: String(err),
60
+ });
61
+ }
62
+ };