mirror of
https://github.com/infrost/RaptorQR.git
synced 2026-09-04 17:07:46 +08:00
Initial implementation: QR-over-GIF Transfer System
- Protocol: packet format, CRC32C, CBOR manifest, fragmentation - FEC: GF(256) arithmetic, xoshiro128** PRNG, systematic RLNC encoder/decoder - QR: generation (qrcode-generator), rasterization (3px/module), decoding (jsQR) - GIF: animated GIF generation (gifenc) with 2-colour palette - Preprocessing: deflate compression, SHA-256 hashing - Sender pipeline: packetizer, frame scheduler with interleaving - Workers: encode, gif generation, decode/reconstruction in Web Workers - UI: Preact SPA with Sender/Receiver tabs, dark theme - Tests: 93 passing (unit, property-based, integration, E2E) - Default profile: Robust (V31-Q, K=24, R=12) - Deployed to /hermes-web-demos/qr-transfer/
This commit is contained in:
+34
@@ -0,0 +1,34 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
@@ -0,0 +1,15 @@
|
||||
# qr
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.3.9. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||
@@ -0,0 +1,408 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "qr",
|
||||
"dependencies": {
|
||||
"cbor-x": "^1.6.4",
|
||||
"fflate": "^0.8.2",
|
||||
"gifenc": "^1.0.3",
|
||||
"jsqr": "^1.4.0",
|
||||
"preact": "10.26.5",
|
||||
"qrcode-generator": "1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "2",
|
||||
"@types/bun": "latest",
|
||||
"happy-dom": "17",
|
||||
"vite": "6",
|
||||
"vitest": "3",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "5.7.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||
|
||||
"@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="],
|
||||
|
||||
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx": ["@babel/plugin-transform-react-jsx@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-module-imports": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/plugin-syntax-jsx": "^7.28.6", "@babel/types": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-development": ["@babel/plugin-transform-react-jsx-development@7.27.1", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-darwin-x64": ["@cbor-extract/cbor-extract-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-linux-arm": ["@cbor-extract/cbor-extract-linux-arm@2.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-linux-arm64": ["@cbor-extract/cbor-extract-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-linux-x64": ["@cbor-extract/cbor-extract-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@preact/preset-vite": ["@preact/preset-vite@2.10.5", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@prefresh/vite": "^2.4.11", "@rollup/pluginutils": "^5.0.0", "babel-plugin-transform-hook-names": "^1.0.2", "debug": "^4.4.3", "magic-string": "^0.30.21", "picocolors": "^1.1.1", "vite-prerender-plugin": "^0.5.8", "zimmerframe": "^1.1.4" }, "peerDependencies": { "@babel/core": "7.x", "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" } }, "sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw=="],
|
||||
|
||||
"@prefresh/babel-plugin": ["@prefresh/babel-plugin@0.5.3", "", {}, "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ=="],
|
||||
|
||||
"@prefresh/core": ["@prefresh/core@1.5.9", "", { "peerDependencies": { "preact": "^10.0.0 || ^11.0.0-0" } }, "sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ=="],
|
||||
|
||||
"@prefresh/utils": ["@prefresh/utils@1.2.1", "", {}, "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw=="],
|
||||
|
||||
"@prefresh/vite": ["@prefresh/vite@2.4.12", "", { "dependencies": { "@babel/core": "^7.22.1", "@prefresh/babel-plugin": "^0.5.2", "@prefresh/core": "^1.5.0", "@prefresh/utils": "^1.2.0", "@rollup/pluginutils": "^4.2.1" }, "peerDependencies": { "preact": "^10.4.0 || ^11.0.0-0", "vite": ">=2.0.0" } }, "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA=="],
|
||||
|
||||
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.2", "", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.2", "", { "os": "android", "cpu": "arm64" }, "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.2", "", { "os": "none", "cpu": "arm64" }, "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"babel-plugin-transform-hook-names": ["babel-plugin-transform-hook-names@1.0.2", "", { "peerDependencies": { "@babel/core": "^7.12.10" } }, "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.25", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA=="],
|
||||
|
||||
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
||||
|
||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001791", "", {}, "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ=="],
|
||||
|
||||
"cbor-extract": ["cbor-extract@2.2.2", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", "@cbor-extract/cbor-extract-linux-arm": "2.2.2", "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", "@cbor-extract/cbor-extract-linux-x64": "2.2.2", "@cbor-extract/cbor-extract-win32-x64": "2.2.2" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng=="],
|
||||
|
||||
"cbor-x": ["cbor-x@1.6.4", "", { "optionalDependencies": { "cbor-extract": "^2.2.2" } }, "sha512-UGKHjp6RHC6QuZ2yy5LCKm7MojM4716DwoSaqwQpaH4DvZvbBTGcoDNTiG9Y2lByXZYFEs9WRkS5tLl96IrF1Q=="],
|
||||
|
||||
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||
|
||||
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
||||
|
||||
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||
|
||||
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||
|
||||
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||
|
||||
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.349", "", {}, "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A=="],
|
||||
|
||||
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"gifenc": ["gifenc@1.0.3", "", {}, "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw=="],
|
||||
|
||||
"happy-dom": ["happy-dom@17.6.3", "", { "dependencies": { "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-UVIHeVhxmxedbWPCfgS55Jg2rDfwf2BCKeylcPSqazLz5w3Kri7Q4xdBJubsr/+VUzFLh0VjIvh13RaDA2/Xug=="],
|
||||
|
||||
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"jsqr": ["jsqr@1.4.0", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="],
|
||||
|
||||
"kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="],
|
||||
|
||||
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
||||
|
||||
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="],
|
||||
|
||||
"node-html-parser": ["node-html-parser@6.1.13", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="],
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"postcss": ["postcss@8.5.13", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag=="],
|
||||
|
||||
"preact": ["preact@10.26.5", "", {}, "sha512-fmpDkgfGU6JYux9teDWLhj9mKN55tyepwYbxHgQuIxbWQzgFg5vk7Mrrtfx7xRxq798ynkY4DDDxZr235Kk+4w=="],
|
||||
|
||||
"qrcode-generator": ["qrcode-generator@1.5.2", "", {}, "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw=="],
|
||||
|
||||
"rollup": ["rollup@4.60.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.2", "@rollup/rollup-android-arm64": "4.60.2", "@rollup/rollup-darwin-arm64": "4.60.2", "@rollup/rollup-darwin-x64": "4.60.2", "@rollup/rollup-freebsd-arm64": "4.60.2", "@rollup/rollup-freebsd-x64": "4.60.2", "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", "@rollup/rollup-linux-arm-musleabihf": "4.60.2", "@rollup/rollup-linux-arm64-gnu": "4.60.2", "@rollup/rollup-linux-arm64-musl": "4.60.2", "@rollup/rollup-linux-loong64-gnu": "4.60.2", "@rollup/rollup-linux-loong64-musl": "4.60.2", "@rollup/rollup-linux-ppc64-gnu": "4.60.2", "@rollup/rollup-linux-ppc64-musl": "4.60.2", "@rollup/rollup-linux-riscv64-gnu": "4.60.2", "@rollup/rollup-linux-riscv64-musl": "4.60.2", "@rollup/rollup-linux-s390x-gnu": "4.60.2", "@rollup/rollup-linux-x64-gnu": "4.60.2", "@rollup/rollup-linux-x64-musl": "4.60.2", "@rollup/rollup-openbsd-x64": "4.60.2", "@rollup/rollup-openharmony-arm64": "4.60.2", "@rollup/rollup-win32-arm64-msvc": "4.60.2", "@rollup/rollup-win32-ia32-msvc": "4.60.2", "@rollup/rollup-win32-x64-gnu": "4.60.2", "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"simple-code-frame": ["simple-code-frame@1.3.0", "", { "dependencies": { "kolorist": "^1.6.0" } }, "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w=="],
|
||||
|
||||
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"stack-trace": ["stack-trace@1.0.0", "", {}, "sha512-H6D7134xi6qONvh7ZHKgviXf+rd3vhGBSvebPZCaUkd8zvQ+7PtDw6CljPTe4cXWNf2IKZGNqw6VJXSb9IgBpA=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
|
||||
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
|
||||
|
||||
"tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
|
||||
|
||||
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
|
||||
|
||||
"vite-prerender-plugin": ["vite-prerender-plugin@0.5.13", "", { "dependencies": { "kolorist": "^1.8.0", "magic-string": "0.x >= 0.26.0", "node-html-parser": "^6.1.12", "simple-code-frame": "^1.3.0", "source-map": "^0.7.4", "stack-trace": "^1.0.0-pre2" }, "peerDependencies": { "vite": "5.x || 6.x || 7.x || 8.x" } }, "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g=="],
|
||||
|
||||
"vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
||||
|
||||
"@prefresh/vite/@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="],
|
||||
|
||||
"@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
|
||||
|
||||
"@prefresh/vite/@rollup/pluginutils/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>QR-over-GIF Transfer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "qr",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "2",
|
||||
"@types/bun": "latest",
|
||||
"happy-dom": "17",
|
||||
"vite": "6",
|
||||
"vitest": "3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "5.7.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"cbor-x": "^1.6.4",
|
||||
"fflate": "^0.8.2",
|
||||
"gifenc": "^1.0.3",
|
||||
"jsqr": "^1.4.0",
|
||||
"preact": "10.26.5",
|
||||
"qrcode-generator": "1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* App shell — top-level layout with tab navigation.
|
||||
*/
|
||||
import { useState } from 'preact/hooks';
|
||||
import { SenderPage } from '@/app/routes/sender';
|
||||
import { ReceiverPage } from '@/app/routes/receiver';
|
||||
|
||||
type Tab = 'sender' | 'receiver';
|
||||
|
||||
const styles: Record<string, Record<string, string | number>> = {
|
||||
container: {
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
background: '#0d1117',
|
||||
color: '#c9d1d9',
|
||||
minHeight: '100vh',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
},
|
||||
header: {
|
||||
background: '#161b22',
|
||||
borderBottom: '1px solid #30363d',
|
||||
padding: '12px 24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 24,
|
||||
},
|
||||
logo: {
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
color: '#58a6ff',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
tabBar: {
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
},
|
||||
tab: {
|
||||
padding: '8px 20px',
|
||||
borderRadius: 6,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
background: 'transparent',
|
||||
color: '#8b949e',
|
||||
transition: 'all 0.15s',
|
||||
},
|
||||
tabActive: {
|
||||
background: '#1f2937',
|
||||
color: '#f0f6fc',
|
||||
},
|
||||
main: {
|
||||
flex: 1,
|
||||
padding: '24px',
|
||||
maxWidth: 960,
|
||||
width: '100%',
|
||||
margin: '0 auto',
|
||||
boxSizing: 'border-box' as const,
|
||||
},
|
||||
};
|
||||
|
||||
export function App() {
|
||||
const [tab, setTab] = useState<Tab>('sender');
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<header style={styles.header}>
|
||||
<span style={styles.logo}>◈ QR-over-GIF</span>
|
||||
<nav style={styles.tabBar}>
|
||||
<button
|
||||
style={{ ...styles.tab, ...(tab === 'sender' ? styles.tabActive : {}) }}
|
||||
onClick={() => setTab('sender')}
|
||||
>
|
||||
📤 Sender
|
||||
</button>
|
||||
<button
|
||||
style={{ ...styles.tab, ...(tab === 'receiver' ? styles.tabActive : {}) }}
|
||||
onClick={() => setTab('receiver')}
|
||||
>
|
||||
📥 Receiver
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
<main style={styles.main}>
|
||||
{tab === 'sender' ? <SenderPage /> : <ReceiverPage />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* Receiver page — camera preview, QR decode, progress tracking, file download.
|
||||
*/
|
||||
import { useState, useCallback, useRef, useEffect } from 'preact/hooks';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SessionInfo {
|
||||
sessionId: string;
|
||||
progress: number; // 0..1
|
||||
solvedGenerations: number;
|
||||
totalGenerations: number;
|
||||
framesDecoded: number;
|
||||
status: 'receiving' | 'complete' | 'error';
|
||||
}
|
||||
|
||||
interface ReceivedFile {
|
||||
data: ArrayBuffer;
|
||||
filename: string;
|
||||
mime: string;
|
||||
}
|
||||
|
||||
interface WorkerProgress {
|
||||
framesDecoded: number;
|
||||
solvedGenerations: number;
|
||||
totalGenerations: number;
|
||||
sessionId: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
// ─── Styles ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type CSSProps = Record<string, string | number>;
|
||||
|
||||
const S = {
|
||||
section: {
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 8,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
} as CSSProps,
|
||||
label: {
|
||||
display: 'block',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: '#8b949e',
|
||||
marginBottom: 6,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
row: {
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap' as const,
|
||||
},
|
||||
btn: {
|
||||
background: '#238636',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
padding: '10px 24px',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
} as CSSProps,
|
||||
btnStop: {
|
||||
background: '#da3633',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
padding: '10px 24px',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
} as CSSProps,
|
||||
btnSecondary: {
|
||||
background: '#21262d',
|
||||
color: '#c9d1d9',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
padding: '10px 24px',
|
||||
fontSize: 15,
|
||||
cursor: 'pointer',
|
||||
} as CSSProps,
|
||||
video: {
|
||||
width: '100%',
|
||||
maxWidth: 480,
|
||||
borderRadius: 6,
|
||||
background: '#000',
|
||||
display: 'block',
|
||||
marginTop: 8,
|
||||
} as CSSProps,
|
||||
progressOuter: {
|
||||
width: '100%',
|
||||
height: 8,
|
||||
background: '#30363d',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
marginTop: 8,
|
||||
} as CSSProps,
|
||||
progressInner: (pct: number): CSSProps => ({
|
||||
width: `${Math.min(100, Math.max(0, pct))}%`,
|
||||
height: '100%',
|
||||
background: pct >= 100 ? '#3fb950' : '#58a6ff',
|
||||
borderRadius: 4,
|
||||
transition: 'width 0.3s ease',
|
||||
}),
|
||||
table: {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse' as const,
|
||||
fontSize: 13,
|
||||
marginTop: 8,
|
||||
} as CSSProps,
|
||||
th: {
|
||||
textAlign: 'left' as const,
|
||||
padding: '8px 10px',
|
||||
borderBottom: '1px solid #30363d',
|
||||
color: '#8b949e',
|
||||
fontWeight: 600,
|
||||
},
|
||||
td: {
|
||||
padding: '8px 10px',
|
||||
borderBottom: '1px solid #21262d',
|
||||
color: '#c9d1d9',
|
||||
},
|
||||
statusBadge: (status: string): CSSProps => ({
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 10,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
background: status === 'complete' ? '#1b3a1b' : status === 'error' ? '#3d1a1a' : '#1f2937',
|
||||
color: status === 'complete' ? '#3fb950' : status === 'error' ? '#f85149' : '#8b949e',
|
||||
}),
|
||||
warn: {
|
||||
background: '#3d2600',
|
||||
border: '1px solid #bb8009',
|
||||
borderRadius: 6,
|
||||
padding: '10px 14px',
|
||||
color: '#d29922',
|
||||
fontSize: 13,
|
||||
marginTop: 8,
|
||||
},
|
||||
sp: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
border: '2px solid #30363d',
|
||||
borderTopColor: '#58a6ff',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'middle',
|
||||
marginRight: 8,
|
||||
} as CSSProps,
|
||||
};
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function ReceiverPage() {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const animRef = useRef<number>(0);
|
||||
const frameTimerRef = useRef<number>(0);
|
||||
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [status, setStatus] = useState('');
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [framesDecoded, setFramesDecoded] = useState(0);
|
||||
const [solvedGens, setSolvedGens] = useState(0);
|
||||
const [totalGens, setTotalGens] = useState(0);
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [receivedFile, setReceivedFile] = useState<ReceivedFile | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// ── Start scanning ───────────────────────────────────────────────────────
|
||||
const startScanning = useCallback(async () => {
|
||||
setError('');
|
||||
setReceivedFile(null);
|
||||
setSessions([]);
|
||||
setProgress(0);
|
||||
setFramesDecoded(0);
|
||||
setSolvedGens(0);
|
||||
setTotalGens(0);
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment', width: { ideal: 640 }, height: { ideal: 640 } },
|
||||
audio: false,
|
||||
});
|
||||
streamRef.current = stream;
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
await videoRef.current.play();
|
||||
}
|
||||
|
||||
// Create decode worker
|
||||
const worker = new Worker(
|
||||
new URL('@/workers/decode.worker.ts', import.meta.url),
|
||||
{ type: 'module' },
|
||||
);
|
||||
workerRef.current = worker;
|
||||
|
||||
worker.onmessage = (e: MessageEvent) => {
|
||||
const msg = e.data;
|
||||
switch (msg.type) {
|
||||
case 'progress': {
|
||||
const p = msg as WorkerProgress;
|
||||
setFramesDecoded(p.framesDecoded);
|
||||
setSolvedGens(p.solvedGenerations);
|
||||
setTotalGens(p.totalGenerations);
|
||||
setProgress(p.totalGenerations > 0 ? p.solvedGenerations / p.totalGenerations : 0);
|
||||
setStatus(p.status);
|
||||
|
||||
// Update sessions
|
||||
if (p.sessionId) {
|
||||
setSessions((prev) => {
|
||||
const sid = p.sessionId as string;
|
||||
const existing = prev.find((s) => s.sessionId === sid);
|
||||
if (existing) {
|
||||
return prev.map((s) =>
|
||||
s.sessionId === sid
|
||||
? {
|
||||
...s,
|
||||
progress: p.totalGenerations > 0 ? p.solvedGenerations / p.totalGenerations : 0,
|
||||
solvedGenerations: p.solvedGenerations,
|
||||
totalGenerations: p.totalGenerations,
|
||||
framesDecoded: p.framesDecoded,
|
||||
status: 'receiving' as const,
|
||||
}
|
||||
: s,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
sessionId: sid,
|
||||
progress: 0,
|
||||
solvedGenerations: 0,
|
||||
totalGenerations: p.totalGenerations,
|
||||
framesDecoded: 0,
|
||||
status: 'receiving' as const,
|
||||
} as SessionInfo,
|
||||
];
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'complete': {
|
||||
const data = msg.data as ArrayBuffer;
|
||||
const filename: string = msg.filename ?? 'recovered';
|
||||
const mime: string = msg.mime ?? 'application/octet-stream';
|
||||
setReceivedFile({ data, filename, mime });
|
||||
setStatus('Complete ✓');
|
||||
setProgress(1);
|
||||
setSessions((prev) =>
|
||||
prev.map((s) =>
|
||||
s.sessionId === msg.sessionId ? { ...s, status: 'complete' as const, progress: 1 } : s,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
setError(msg.message);
|
||||
setSessions((prev) =>
|
||||
prev.map((s) =>
|
||||
s.sessionId === msg.sessionId ? { ...s, status: 'error' as const } : s,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (err) => {
|
||||
setError(`Worker error: ${err.message}`);
|
||||
};
|
||||
|
||||
setScanning(true);
|
||||
setStatus('Scanning…');
|
||||
|
||||
// Start frame capture loop
|
||||
let lastCapture = 0;
|
||||
const CAPTURE_INTERVAL = 150; // ms between capture attempts
|
||||
const loop = (time: number) => {
|
||||
if (!scanning) return;
|
||||
if (time - lastCapture >= CAPTURE_INTERVAL) {
|
||||
captureFrame();
|
||||
lastCapture = time;
|
||||
}
|
||||
animRef.current = requestAnimationFrame(loop);
|
||||
};
|
||||
animRef.current = requestAnimationFrame(loop);
|
||||
} catch (err: any) {
|
||||
setError(`Camera error: ${err.message ?? String(err)}`);
|
||||
}
|
||||
}, [scanning]);
|
||||
|
||||
// ── Stop scanning ────────────────────────────────────────────────────────
|
||||
const stopScanning = useCallback(() => {
|
||||
setScanning(false);
|
||||
cancelAnimationFrame(animRef.current);
|
||||
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
videoRef.current.srcObject = null;
|
||||
}
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
if (workerRef.current) {
|
||||
workerRef.current.terminate();
|
||||
workerRef.current = null;
|
||||
}
|
||||
setStatus('Stopped');
|
||||
}, []);
|
||||
|
||||
// ── Capture frame ────────────────────────────────────────────────────────
|
||||
const captureFrame = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const worker = workerRef.current;
|
||||
if (!video || !canvas || !worker || video.readyState < 2) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size to a reasonable capture resolution
|
||||
const cw = Math.min(video.videoWidth || 640, 640);
|
||||
const ch = Math.min(video.videoHeight || 640, 640);
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
|
||||
// Center-crop the video
|
||||
const vw = video.videoWidth || 640;
|
||||
const vh = video.videoHeight || 640;
|
||||
const minDim = Math.min(vw, vh);
|
||||
const sx = (vw - minDim) / 2;
|
||||
const sy = (vh - minDim) / 2;
|
||||
|
||||
ctx.drawImage(video, sx, sy, minDim, minDim, 0, 0, cw, ch);
|
||||
const imageData = ctx.getImageData(0, 0, cw, ch);
|
||||
|
||||
worker.postMessage({ type: 'frame', imageData });
|
||||
}, []);
|
||||
|
||||
// ── Download recovered file ──────────────────────────────────────────────
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!receivedFile) return;
|
||||
const blob = new Blob([receivedFile.data], { type: receivedFile.mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = receivedFile.filename || 'recovered-file';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, [receivedFile]);
|
||||
|
||||
// ── Cleanup on unmount ───────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelAnimationFrame(animRef.current);
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((t) => t.stop());
|
||||
}
|
||||
if (workerRef.current) {
|
||||
workerRef.current.terminate();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div>
|
||||
{/* ── Camera preview ──────────────────────────────────────────────── */}
|
||||
<div style={S.section}>
|
||||
<div style={S.label}>Camera</div>
|
||||
<video ref={videoRef} style={S.video} playsInline muted />
|
||||
<canvas ref={canvasRef} style={{ display: 'none' }} />
|
||||
<div style={{ ...S.row, marginTop: 10 }}>
|
||||
{!scanning ? (
|
||||
<button style={S.btn} onClick={startScanning}>
|
||||
▶ Start Scan
|
||||
</button>
|
||||
) : (
|
||||
<button style={S.btnStop} onClick={stopScanning}>
|
||||
■ Stop Scan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && <div style={S.warn}>⚠ {error}</div>}
|
||||
</div>
|
||||
|
||||
{/* ── Status + progress ────────────────────────────────────────────── */}
|
||||
<div style={S.section}>
|
||||
<div style={S.label}>Status</div>
|
||||
<div style={{ ...S.row, gap: 16 }}>
|
||||
<span>
|
||||
<strong>Status:</strong> {status || 'Idle'}
|
||||
</span>
|
||||
<span>
|
||||
<strong>Frames:</strong> {framesDecoded}
|
||||
</span>
|
||||
<span>
|
||||
<strong>Generations:</strong> {solvedGens}/{totalGens}
|
||||
</span>
|
||||
</div>
|
||||
{totalGens > 0 && (
|
||||
<div style={S.progressOuter}>
|
||||
<div style={S.progressInner(progress * 100)} />
|
||||
</div>
|
||||
)}
|
||||
{scanning && (
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: '#8b949e' }}>
|
||||
<span style={S.sp} /> Scanning for QR codes…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Sessions table ───────────────────────────────────────────────── */}
|
||||
{sessions.length > 0 && (
|
||||
<div style={S.section}>
|
||||
<div style={S.label}>Sessions</div>
|
||||
<table style={S.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={S.th}>Session ID</th>
|
||||
<th style={S.th}>Progress</th>
|
||||
<th style={S.th}>Generations</th>
|
||||
<th style={S.th}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sessions.map((s) => (
|
||||
<tr key={s.sessionId}>
|
||||
<td style={S.td}>
|
||||
{s.sessionId.length > 16
|
||||
? `${s.sessionId.slice(0, 16)}…`
|
||||
: s.sessionId}
|
||||
</td>
|
||||
<td style={S.td}>
|
||||
<div
|
||||
style={{
|
||||
...S.progressOuter,
|
||||
marginTop: 0,
|
||||
width: 100,
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
>
|
||||
<div style={S.progressInner(s.progress * 100)} />
|
||||
</div>
|
||||
<span style={{ marginLeft: 8, fontSize: 12 }}>
|
||||
{Math.round(s.progress * 100)}%
|
||||
</span>
|
||||
</td>
|
||||
<td style={S.td}>
|
||||
{s.solvedGenerations}/{s.totalGenerations}
|
||||
</td>
|
||||
<td style={S.td}>
|
||||
<span style={S.statusBadge(s.status)}>{s.status}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Download recovered file ──────────────────────────────────────── */}
|
||||
{receivedFile && (
|
||||
<div style={S.section}>
|
||||
<div style={S.label}>Recovered File</div>
|
||||
<p style={{ margin: '6px 0', fontSize: 14 }}>
|
||||
<strong>File:</strong> {receivedFile.filename || '(unnamed)'} ·{' '}
|
||||
{formatBytes(receivedFile.data.byteLength)} ·{' '}
|
||||
{receivedFile.mime}
|
||||
</p>
|
||||
<button style={S.btn} onClick={handleDownload}>
|
||||
⬇ Download Recovered File
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* Sender page — text/file input, profile selection, GIF generation preview.
|
||||
*/
|
||||
import { useState, useCallback } from 'preact/hooks';
|
||||
import { ProfileId, PROFILES } from '@/core/protocol/constants';
|
||||
import type { ManifestData } from '@/core/protocol/manifest';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type InputMode = 'text' | 'file';
|
||||
type CompMode = 'auto' | 'off';
|
||||
|
||||
interface EncodeStats {
|
||||
originalSize: number;
|
||||
preprocessedSize: number;
|
||||
frameCount: number;
|
||||
estimatedGifBytes: number;
|
||||
totalGenerations: number;
|
||||
packetsPerGen: number;
|
||||
}
|
||||
|
||||
interface GifResult {
|
||||
gifData: ArrayBuffer;
|
||||
width: number;
|
||||
height: number;
|
||||
frameCount: number;
|
||||
}
|
||||
|
||||
// ─── Inline styles ───────────────────────────────────────────────────────────
|
||||
|
||||
type CSSProps = Record<string, string | number>;
|
||||
|
||||
const S = {
|
||||
section: {
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 8,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
} as CSSProps,
|
||||
label: {
|
||||
display: 'block',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: '#8b949e',
|
||||
marginBottom: 6,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
row: {
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap' as const,
|
||||
},
|
||||
select: {
|
||||
background: '#0d1117',
|
||||
color: '#c9d1d9',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
padding: '8px 12px',
|
||||
fontSize: 14,
|
||||
},
|
||||
btn: {
|
||||
background: '#238636',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
padding: '10px 24px',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
} as CSSProps,
|
||||
btnDanger: {
|
||||
background: '#da3633',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
padding: '10px 24px',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
} as CSSProps,
|
||||
btnSecondary: {
|
||||
background: '#21262d',
|
||||
color: '#c9d1d9',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
padding: '10px 24px',
|
||||
fontSize: 15,
|
||||
cursor: 'pointer',
|
||||
} as CSSProps,
|
||||
textarea: {
|
||||
width: '100%',
|
||||
boxSizing: 'border-box' as const,
|
||||
background: '#0d1117',
|
||||
color: '#c9d1d9',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
resize: 'vertical' as const,
|
||||
minHeight: 120,
|
||||
},
|
||||
preview: {
|
||||
background: '#000',
|
||||
borderRadius: 8,
|
||||
imageRendering: 'pixelated' as const,
|
||||
maxWidth: '100%',
|
||||
display: 'block',
|
||||
margin: '12px 0',
|
||||
} as CSSProps,
|
||||
infoGrid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
} as CSSProps,
|
||||
infoLabel: { color: '#8b949e' },
|
||||
infoValue: { color: '#f0f6fc', fontFamily: 'monospace' },
|
||||
warn: {
|
||||
background: '#3d2600',
|
||||
border: '1px solid #bb8009',
|
||||
borderRadius: 6,
|
||||
padding: '10px 14px',
|
||||
color: '#d29922',
|
||||
fontSize: 13,
|
||||
marginTop: 8,
|
||||
},
|
||||
toggleGroup: {
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
background: '#0d1117',
|
||||
borderRadius: 6,
|
||||
padding: 2,
|
||||
} as CSSProps,
|
||||
toggleBtn: (active: boolean): CSSProps => ({
|
||||
padding: '6px 14px',
|
||||
borderRadius: 4,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
background: active ? '#1f2937' : 'transparent',
|
||||
color: active ? '#f0f6fc' : '#8b949e',
|
||||
transition: 'all 0.15s',
|
||||
}),
|
||||
spinner: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
border: '2px solid #30363d',
|
||||
borderTopColor: '#58a6ff',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'middle',
|
||||
marginRight: 8,
|
||||
} as CSSProps,
|
||||
};
|
||||
|
||||
const PROFILE_OPTIONS: { id: ProfileId; label: string }[] = [
|
||||
{ id: ProfileId.ROBUST, label: 'Robust' },
|
||||
{ id: ProfileId.BALANCED, label: 'Balanced' },
|
||||
{ id: ProfileId.FAST, label: 'Fast' },
|
||||
];
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function SenderPage() {
|
||||
const [mode, setMode] = useState<InputMode>('text');
|
||||
const [text, setText] = useState('');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [profileId, setProfileId] = useState<ProfileId>(ProfileId.ROBUST);
|
||||
const [compression, setCompression] = useState<CompMode>('auto');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState('');
|
||||
const [gifResult, setGifResult] = useState<GifResult | null>(null);
|
||||
const [stats, setStats] = useState<EncodeStats | null>(null);
|
||||
const [gifUrl, setGifUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleFile = useCallback((e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
setFile(input.files?.[0] ?? null);
|
||||
}, []);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
setError('');
|
||||
setGifResult(null);
|
||||
setStats(null);
|
||||
if (gifUrl) { URL.revokeObjectURL(gifUrl); setGifUrl(null); }
|
||||
|
||||
// --- read input data ---
|
||||
let data: ArrayBuffer;
|
||||
let filename: string;
|
||||
let mime: string;
|
||||
|
||||
if (mode === 'text') {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) { setError('Please enter some text.'); return; }
|
||||
data = new TextEncoder().encode(trimmed).buffer;
|
||||
filename = '';
|
||||
mime = 'text/plain';
|
||||
} else {
|
||||
if (!file) { setError('Please select a file.'); return; }
|
||||
data = await file.arrayBuffer();
|
||||
filename = file.name;
|
||||
mime = file.type || 'application/octet-stream';
|
||||
}
|
||||
|
||||
const profile = PROFILES[profileId];
|
||||
const compress = compression === 'auto' ? data.byteLength > 512 : false;
|
||||
|
||||
setBusy(true);
|
||||
setStatus('Encoding data…');
|
||||
|
||||
try {
|
||||
// ── Step 1: Encode worker ──────────────────────────────────────────
|
||||
const encodeWorker = new Worker(
|
||||
new URL('@/workers/encode.worker.ts', import.meta.url),
|
||||
{ type: 'module' },
|
||||
);
|
||||
|
||||
const encoded = await new Promise<{
|
||||
packets: Uint8Array[];
|
||||
manifest: ManifestData;
|
||||
stats: EncodeStats;
|
||||
}>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Encode worker timed out')), 120_000);
|
||||
encodeWorker.onmessage = (e: MessageEvent) => {
|
||||
clearTimeout(timeout);
|
||||
if (e.data.type === 'encoded') {
|
||||
resolve(e.data);
|
||||
} else if (e.data.type === 'error') {
|
||||
reject(new Error(e.data.message));
|
||||
}
|
||||
};
|
||||
encodeWorker.onerror = (err) => { clearTimeout(timeout); reject(err); };
|
||||
encodeWorker.postMessage(
|
||||
{ type: 'encode', data, profileId, filename, mime, compress },
|
||||
[data],
|
||||
);
|
||||
});
|
||||
encodeWorker.terminate();
|
||||
|
||||
setStats(encoded.stats);
|
||||
setStatus(`Generating GIF (${encoded.stats.frameCount} frames)…`);
|
||||
|
||||
// ── Step 2: GIF worker ─────────────────────────────────────────────
|
||||
const gifWorker = new Worker(
|
||||
new URL('@/workers/gif.worker.ts', import.meta.url),
|
||||
{ type: 'module' },
|
||||
);
|
||||
|
||||
const gif = await new Promise<GifResult>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('GIF worker timed out')), 120_000);
|
||||
gifWorker.onmessage = (e: MessageEvent) => {
|
||||
clearTimeout(timeout);
|
||||
if (e.data.type === 'gifReady') {
|
||||
resolve({
|
||||
gifData: e.data.gifData,
|
||||
width: e.data.width,
|
||||
height: e.data.height,
|
||||
frameCount: e.data.frameCount,
|
||||
});
|
||||
} else if (e.data.type === 'error') {
|
||||
reject(new Error(e.data.message));
|
||||
}
|
||||
};
|
||||
gifWorker.onerror = (err) => { clearTimeout(timeout); reject(err); };
|
||||
// Transfer packets to avoid copy
|
||||
const transfer: ArrayBufferLike[] = [];
|
||||
const transferPackets = encoded.packets.map(p => {
|
||||
if (p.buffer.byteLength <= 1024 * 1024) { transfer.push(p.buffer as ArrayBuffer); }
|
||||
return p;
|
||||
});
|
||||
gifWorker.postMessage(
|
||||
{ type: 'generate', packets: transferPackets, manifest: encoded.manifest, profile },
|
||||
transfer.length > 0 ? (transfer as ArrayBuffer[]) : undefined,
|
||||
);
|
||||
});
|
||||
gifWorker.terminate();
|
||||
|
||||
// ── Step 3: show result ─────────────────────────────────────────────
|
||||
const url = URL.createObjectURL(new Blob([gif.gifData], { type: 'image/gif' }));
|
||||
setGifUrl(url);
|
||||
setGifResult(gif);
|
||||
setStatus('Done ✓');
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [mode, text, file, profileId, compression, gifUrl]);
|
||||
|
||||
// ─── Download handler ──────────────────────────────────────────────────────
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!gifResult) return;
|
||||
const blob = new Blob([gifResult.gifData], { type: 'image/gif' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `qr-transfer-${profileId}-${stats?.totalGenerations ?? 0}g.gif`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [gifResult, profileId, stats]);
|
||||
|
||||
// ─── Compute warnings ──────────────────────────────────────────────────────
|
||||
const showWarning = stats && stats.estimatedGifBytes > 5 * 1024 * 1024;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ── Input mode toggle ───────────────────────────────────────────── */}
|
||||
<div style={S.section}>
|
||||
<div style={S.row}>
|
||||
<span style={S.label}>Input mode</span>
|
||||
<div style={S.toggleGroup}>
|
||||
<button style={S.toggleBtn(mode === 'text')} onClick={() => setMode('text')}>Text</button>
|
||||
<button style={S.toggleBtn(mode === 'file')} onClick={() => setMode('file')}>File</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'text' ? (
|
||||
<textarea
|
||||
style={{ ...S.textarea, marginTop: 10 }}
|
||||
placeholder="Type or paste text to transfer…"
|
||||
value={text}
|
||||
onInput={(e) => setText((e.target as HTMLTextAreaElement).value)}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<input type="file" onChange={handleFile} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Profile + compression ───────────────────────────────────────── */}
|
||||
<div style={S.section}>
|
||||
<div style={S.row}>
|
||||
<div>
|
||||
<span style={S.label}>Profile</span>
|
||||
<select
|
||||
style={S.select}
|
||||
value={profileId}
|
||||
onChange={(e) => setProfileId(Number((e.target as HTMLSelectElement).value) as ProfileId)}
|
||||
>
|
||||
{PROFILE_OPTIONS.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.label} (V{PROFILES[o.id].qrVersion}-{PROFILES[o.id].eccLevel}, K={PROFILES[o.id].k})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<span style={S.label}>Compression</span>
|
||||
<div style={S.toggleGroup}>
|
||||
<button style={S.toggleBtn(compression === 'auto')} onClick={() => setCompression('auto')}>Auto</button>
|
||||
<button style={S.toggleBtn(compression === 'off')} onClick={() => setCompression('off')}>Off</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Generate ────────────────────────────────────────────────────── */}
|
||||
<div style={S.section}>
|
||||
<button
|
||||
style={busy ? { ...S.btn, opacity: 0.6, cursor: 'not-allowed' } : S.btn}
|
||||
disabled={busy}
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
{busy ? (
|
||||
<>
|
||||
<span style={S.spinner} />
|
||||
{status || 'Processing…'}
|
||||
</>
|
||||
) : (
|
||||
'Generate GIF'
|
||||
)}
|
||||
</button>
|
||||
{error && <div style={S.warn}>⚠ {error}</div>}
|
||||
</div>
|
||||
|
||||
{/* ── Preview ─────────────────────────────────────────────────────── */}
|
||||
{gifUrl && gifResult && (
|
||||
<div style={S.section}>
|
||||
<div style={S.label}>Preview</div>
|
||||
<img src={gifUrl} alt="QR transfer GIF" style={S.preview} />
|
||||
<div style={{ ...S.row, marginTop: 8 }}>
|
||||
<button style={S.btn} onClick={handleDownload}>
|
||||
⬇ Download GIF ({Math.round(gifResult.gifData.byteLength / 1024)} KB)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Stats ───────────────────────────────────────────────────────── */}
|
||||
{stats && (
|
||||
<div style={S.section}>
|
||||
<div style={S.label}>Transfer Info</div>
|
||||
<div style={S.infoGrid}>
|
||||
<span style={S.infoLabel}>Original size</span>
|
||||
<span style={S.infoValue}>{formatBytes(stats.originalSize)}</span>
|
||||
<span style={S.infoLabel}>Preprocessed size</span>
|
||||
<span style={S.infoValue}>{formatBytes(stats.preprocessedSize)}</span>
|
||||
<span style={S.infoLabel}>Frame count</span>
|
||||
<span style={S.infoValue}>{stats.frameCount}</span>
|
||||
<span style={S.infoLabel}>Generations</span>
|
||||
<span style={S.infoValue}>{stats.totalGenerations}</span>
|
||||
<span style={S.infoLabel}>Estimated GIF size</span>
|
||||
<span style={S.infoValue}>{formatBytes(stats.estimatedGifBytes)}</span>
|
||||
<span style={S.infoLabel}>Estimated throughput</span>
|
||||
<span style={S.infoValue}>
|
||||
{estimateThroughput(stats.originalSize, stats.frameCount, profileId)}
|
||||
</span>
|
||||
</div>
|
||||
{showWarning && (
|
||||
<div style={S.warn}>
|
||||
⚠ Large GIF ({formatBytes(stats.estimatedGifBytes)}) — transfer may take several minutes.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function estimateThroughput(originalSize: number, frameCount: number, profileId: ProfileId): string {
|
||||
const profile = PROFILES[profileId];
|
||||
const totalTimeSec = (frameCount * profile.frameDelay * 10) / 1000; // frameDelay is in centiseconds
|
||||
if (totalTimeSec <= 0) return '—';
|
||||
const bps = (originalSize * 8) / totalTimeSec;
|
||||
if (bps < 1000) return `${bps.toFixed(0)} bps`;
|
||||
if (bps < 1_000_000) return `${(bps / 1000).toFixed(1)} Kbps`;
|
||||
return `${(bps / 1_000_000).toFixed(2)} Mbps`;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* GF(256) finite field arithmetic module.
|
||||
*
|
||||
* Uses the irreducible polynomial 0x11d (x^8 + x^4 + x^3 + x^2 + 1)
|
||||
* which is the same polynomial used in AES. Pre-computed log and antilog
|
||||
* (exp) tables enable fast multiplication, division, and inversion.
|
||||
*
|
||||
* GF(256) elements are represented as numbers 0..255.
|
||||
* Addition is XOR (same as subtraction).
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** Size of the field (2^8 = 256 elements). */
|
||||
const GF_SIZE = 256;
|
||||
|
||||
/** Length of the log/exp tables (2 * GF_SIZE - 2 = 510 for simplification). */
|
||||
const GF_TABLE_SIZE = 512;
|
||||
|
||||
/** The irreducible polynomial: x^8 + x^4 + x^3 + x^2 + 1 = 0x11d. */
|
||||
const IRREDUCIBLE_POLY = 0x11d;
|
||||
|
||||
/**
|
||||
* Log table: log[value] = discrete logarithm (exponent) in GF(256).
|
||||
* log[0] is undefined (0 has no log); we set it to 0 for convenience.
|
||||
*/
|
||||
const logTable = new Uint8Array(GF_SIZE);
|
||||
|
||||
/**
|
||||
* Antilog (exponential) table: exp[power] = field element.
|
||||
* Sized larger than GF_SIZE to avoid modulo operations during multiplication.
|
||||
*/
|
||||
const expTable = new Uint8Array(GF_TABLE_SIZE);
|
||||
|
||||
/**
|
||||
* Initialize the log and antilog tables for the GF(256) field.
|
||||
* Builds the field by starting with generator 2 (α = 0x02) and
|
||||
* multiplying repeatedly, wrapping through the irreducible polynomial.
|
||||
*/
|
||||
function initTables(): void {
|
||||
let x = 1;
|
||||
// Index 0 is special: log[0] is undefined, but we set it to 0
|
||||
// We'll fill starting from index 1
|
||||
for (let i = 0; i < GF_SIZE - 1; i++) {
|
||||
expTable[i] = x;
|
||||
logTable[x] = i;
|
||||
// Multiply x by 2 (the generator α = 0x02) in GF(256)
|
||||
x = x << 1;
|
||||
if (x >= GF_SIZE) {
|
||||
x ^= IRREDUCIBLE_POLY;
|
||||
}
|
||||
}
|
||||
|
||||
// Duplicate the exp table for the remainder (GF_TABLE_SIZE entries)
|
||||
// This lets mul(a,b) = exp[log[a] + log[b]] without a modulo check
|
||||
for (let i = GF_SIZE - 1; i < GF_TABLE_SIZE; i++) {
|
||||
expTable[i] = expTable[i - (GF_SIZE - 1)];
|
||||
}
|
||||
|
||||
// Fill log[0] — we'll leave it as 0, and mul/div will handle 0 specially
|
||||
logTable[0] = 0;
|
||||
}
|
||||
|
||||
// Initialize tables at module load time
|
||||
initTables();
|
||||
|
||||
/**
|
||||
* Add two elements in GF(256). Addition is XOR.
|
||||
* Alias: sub(a, b) === add(a, b).
|
||||
*
|
||||
* @param a - Field element (0..255)
|
||||
* @param b - Field element (0..255)
|
||||
* @returns a ⊕ b (XOR)
|
||||
*/
|
||||
export function add(a: number, b: number): number {
|
||||
return (a ^ b) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtract two elements in GF(256). Subtraction is same as addition (XOR).
|
||||
*
|
||||
* @param a - Field element (0..255)
|
||||
* @param b - Field element (0..255)
|
||||
* @returns a ⊕ b (XOR)
|
||||
*/
|
||||
export function sub(a: number, b: number): number {
|
||||
return add(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiply two elements in GF(256).
|
||||
* Uses pre-computed log/antilog tables for O(1) multiplication.
|
||||
*
|
||||
* @param a - Field element (0..255)
|
||||
* @param b - Field element (0..255)
|
||||
* @returns a * b in GF(256)
|
||||
*/
|
||||
export function mul(a: number, b: number): number {
|
||||
if (a === 0 || b === 0) {
|
||||
return 0;
|
||||
}
|
||||
// logTable[a] + logTable[b] in Z_255
|
||||
const sum = logTable[a] + logTable[b];
|
||||
// expTable[sum] works because we duplicated the table
|
||||
return expTable[sum];
|
||||
}
|
||||
|
||||
/**
|
||||
* Divide a by b in GF(256).
|
||||
*
|
||||
* @param a - Numerator field element (0..255)
|
||||
* @param b - Denominator field element (0..255), must not be 0
|
||||
* @returns a / b in GF(256)
|
||||
* @throws {RangeError} If b is 0 (division by zero)
|
||||
*/
|
||||
export function div(a: number, b: number): number {
|
||||
if (b === 0) {
|
||||
throw new RangeError('GF(256) division by zero');
|
||||
}
|
||||
if (a === 0) {
|
||||
return 0;
|
||||
}
|
||||
// logTable[a] - logTable[b] in Z_255, add 255 to avoid negative
|
||||
const diff = (logTable[a] - logTable[b] + 255) % 255;
|
||||
return expTable[diff];
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise a field element to a non-negative integer power.
|
||||
*
|
||||
* @param a - Field element (0..255)
|
||||
* @param n - Non-negative integer exponent
|
||||
* @returns a^n in GF(256)
|
||||
*/
|
||||
export function pow(a: number, n: number): number {
|
||||
if (n === 0) {
|
||||
return 1;
|
||||
}
|
||||
if (a === 0) {
|
||||
return 0;
|
||||
}
|
||||
// (log[a] * n) mod 255
|
||||
const idx = (logTable[a] * n) % 255;
|
||||
return expTable[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the multiplicative inverse of a field element.
|
||||
* inv(0) is undefined but we return 0 for convenience.
|
||||
*
|
||||
* @param a - Field element (0..255)
|
||||
* @returns a^{-1} in GF(256), or 0 if a === 0
|
||||
*/
|
||||
export function inv(a: number): number {
|
||||
if (a === 0) {
|
||||
return 0; // 0 has no inverse, return 0
|
||||
}
|
||||
// By Fermat's Little Theorem in GF(256): a^{-1} = a^{254}
|
||||
// Or equivalently: exp[255 - log[a]]
|
||||
return expTable[255 - logTable[a]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random non-zero field element using the provided RNG.
|
||||
*
|
||||
* @param nextByte - Function that returns a random byte (0..255)
|
||||
* @returns A random non-zero GF(256) element
|
||||
*/
|
||||
export function randomNonZero(nextByte: () => number): number {
|
||||
let v: number;
|
||||
do {
|
||||
v = nextByte();
|
||||
} while (v === 0);
|
||||
return v;
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* Incremental RLNC decoder using Gaussian elimination over GF(256).
|
||||
*
|
||||
* Maintains per-generation matrix state and incrementally incorporates
|
||||
* new coded/systematic symbols. When enough linearly independent symbols
|
||||
* have been received (rank K), performs back-substitution to reconstruct
|
||||
* the original source symbols.
|
||||
*
|
||||
* Coefficient vector derivation is kept in sync with the encoder
|
||||
* (same xoshiro128** PRNG seeded from session/generation/coding triple).
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { add, sub, mul, div, inv } from './gf256';
|
||||
import { Xoshiro128 } from './xoshiro';
|
||||
import { deriveCoefficientSeed, generateCoefficients } from './rlnc_encoder';
|
||||
|
||||
/**
|
||||
* Internal representation of a single row in the augmented matrix.
|
||||
* Each row has a coefficient vector (length K) over GF(256) and
|
||||
* the corresponding coded symbol data bytes (length L).
|
||||
*/
|
||||
interface MatrixRow {
|
||||
/** Coefficient vector for this row (length K) */
|
||||
coeffs: Uint8Array;
|
||||
/** Data bytes (the RHS of the linear system) */
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental RLNC decoder using Gaussian elimination over GF(256).
|
||||
*
|
||||
* Manages one generation: receives symbols (systematic or coded) and
|
||||
* incrementally reduces the coefficient matrix to row-reduced echelon form.
|
||||
* Once rank reaches K, the source symbols can be read out.
|
||||
*/
|
||||
export class RLNCDecoder {
|
||||
/** Number of source symbols in this generation */
|
||||
readonly k: number;
|
||||
/** Length of each symbol in bytes */
|
||||
readonly symbolLength: number;
|
||||
/** Session identifier (for coefficient derivation) */
|
||||
readonly sessionId: number;
|
||||
/** Generation index within the session */
|
||||
readonly generationIndex: number;
|
||||
/** Coding seed (for coefficient derivation) */
|
||||
readonly codingSeed: number;
|
||||
|
||||
/** Rows currently in the reduced row-echelon matrix, sorted by pivot column */
|
||||
private rows: MatrixRow[] = [];
|
||||
/** Mapping from column index → row index (or -1 if column is not a pivot) */
|
||||
private pivotForColumn: number[];
|
||||
/** Current rank (number of linearly independent rows) */
|
||||
private _rank: number = 0;
|
||||
/** Whether the system is fully solved (rank === K) */
|
||||
private _solved: boolean = false;
|
||||
/** Reconstructed source symbols (valid only when solved) */
|
||||
private _sourceSymbols: Uint8Array[] | null = null;
|
||||
|
||||
constructor(
|
||||
k: number,
|
||||
symbolLength: number,
|
||||
sessionId: number,
|
||||
generationIndex: number,
|
||||
codingSeed: number
|
||||
) {
|
||||
this.k = k;
|
||||
this.symbolLength = symbolLength;
|
||||
this.sessionId = sessionId;
|
||||
this.generationIndex = generationIndex;
|
||||
this.codingSeed = codingSeed;
|
||||
this.pivotForColumn = new Array(k).fill(-1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current rank of the received coefficient matrix.
|
||||
* When rank reaches K, the system can be solved.
|
||||
*/
|
||||
get rank(): number {
|
||||
return this._rank;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the linear system has full rank and source symbols
|
||||
* have been reconstructed.
|
||||
*/
|
||||
isSolved(): boolean {
|
||||
return this._solved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a received symbol (coded or systematic) to the decoder.
|
||||
*
|
||||
* @param symbol - The received symbol data bytes
|
||||
* @param coefficients - The coefficient vector (length K) over GF(256)
|
||||
* @returns true if the symbol was accepted (linearly independent), false if redundant
|
||||
*/
|
||||
addSymbol(symbol: Uint8Array, coefficients: Uint8Array): boolean {
|
||||
if (symbol.length !== this.symbolLength) {
|
||||
throw new RangeError(
|
||||
`addSymbol: expected symbol length ${this.symbolLength}, got ${symbol.length}`
|
||||
);
|
||||
}
|
||||
if (coefficients.length !== this.k) {
|
||||
throw new RangeError(
|
||||
`addSymbol: expected coefficient length ${this.k}, got ${coefficients.length}`
|
||||
);
|
||||
}
|
||||
if (this._solved) {
|
||||
return false; // Already solved, ignore further symbols
|
||||
}
|
||||
|
||||
// Working copy: the new row we're inserting
|
||||
const row: MatrixRow = {
|
||||
coeffs: new Uint8Array(coefficients),
|
||||
data: new Uint8Array(symbol),
|
||||
};
|
||||
|
||||
// --- STEP 1: Forward elimination using existing pivots ---
|
||||
// For each existing pivot column (in order of increasing column),
|
||||
// eliminate that column from the new row.
|
||||
for (let col = 0; col < this.k; col++) {
|
||||
const pivotRowIdx = this.pivotForColumn[col];
|
||||
if (pivotRowIdx < 0) continue;
|
||||
if (row.coeffs[col] === 0) continue;
|
||||
|
||||
// Eliminate: row = row - (coeff / pivot) * pivotRow
|
||||
const pivotRow = this.rows[pivotRowIdx];
|
||||
// pivotRow.coeffs[col] is always 1 (RREF invariant)
|
||||
const factor = row.coeffs[col]; // since pivot = 1, factor = coeff
|
||||
this.eliminateFromRow(row, pivotRow, factor, col);
|
||||
}
|
||||
|
||||
// --- STEP 2: Find first non-zero coefficient (new pivot) ---
|
||||
let pivotCol = -1;
|
||||
for (let col = 0; col < this.k; col++) {
|
||||
if (row.coeffs[col] !== 0) {
|
||||
pivotCol = col;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If all-zero, the row is linearly dependent — discard
|
||||
if (pivotCol < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- STEP 3: Scale row so pivot = 1 ---
|
||||
const pivotValue = row.coeffs[pivotCol];
|
||||
if (pivotValue !== 1) {
|
||||
const scaleFactor = inv(pivotValue);
|
||||
// Scale all remaining coefficients (pivot column and beyond)
|
||||
for (let col = pivotCol; col < this.k; col++) {
|
||||
row.coeffs[col] = mul(row.coeffs[col], scaleFactor);
|
||||
}
|
||||
// Scale data bytes
|
||||
for (let b = 0; b < this.symbolLength; b++) {
|
||||
row.data[b] = mul(row.data[b], scaleFactor);
|
||||
}
|
||||
}
|
||||
|
||||
// --- STEP 4: Eliminate this new pivot from all existing rows ---
|
||||
// (to maintain RREF — both above and below)
|
||||
for (let i = 0; i < this.rows.length; i++) {
|
||||
const existingRow = this.rows[i];
|
||||
if (existingRow.coeffs[pivotCol] === 0) continue;
|
||||
|
||||
const factor = existingRow.coeffs[pivotCol];
|
||||
this.eliminateFromRow(existingRow, row, factor, pivotCol);
|
||||
}
|
||||
|
||||
// --- STEP 5: Insert the new row, maintaining pivot-column order ---
|
||||
// Find the correct insertion position (rows sorted by pivot column)
|
||||
let insertIdx = 0;
|
||||
while (insertIdx < this.rows.length) {
|
||||
const existingPivot = this.findPivot(this.rows[insertIdx]);
|
||||
if (existingPivot < 0) break; // shouldn't happen
|
||||
if (existingPivot < pivotCol) {
|
||||
insertIdx++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update pivot mapping for the new row
|
||||
this.pivotForColumn[pivotCol] = insertIdx;
|
||||
// Shift existing pivot mappings for columns that moved
|
||||
for (let col = 0; col < this.k; col++) {
|
||||
if (this.pivotForColumn[col] >= insertIdx && col !== pivotCol) {
|
||||
this.pivotForColumn[col]++;
|
||||
}
|
||||
}
|
||||
|
||||
this.rows.splice(insertIdx, 0, row);
|
||||
this._rank++;
|
||||
|
||||
// --- STEP 6: Check if solved ---
|
||||
if (this._rank === this.k) {
|
||||
this.solve();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reconstructed source symbols. Valid only when isSolved() returns true.
|
||||
*
|
||||
* @returns Array of K Uint8Arrays, each being a reconstructed source symbol,
|
||||
* or null if not yet solved
|
||||
*/
|
||||
getSourceSymbols(): Uint8Array[] | null {
|
||||
return this._sourceSymbols ? this._sourceSymbols.map(s => new Uint8Array(s)) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the pivot column of a matrix row (the first non-zero coefficient).
|
||||
*/
|
||||
private findPivot(row: MatrixRow): number {
|
||||
for (let col = 0; col < this.k; col++) {
|
||||
if (row.coeffs[col] !== 0) return col;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminate a factor-scaled version of `srcRow` from `targetRow`.
|
||||
* target = target - factor * srcRow
|
||||
* Both row coefficient vectors and data bytes are updated.
|
||||
*/
|
||||
private eliminateFromRow(
|
||||
target: MatrixRow,
|
||||
srcRow: MatrixRow,
|
||||
factor: number,
|
||||
startCol: number
|
||||
): void {
|
||||
// Eliminate coefficients from startCol onwards
|
||||
for (let col = startCol; col < this.k; col++) {
|
||||
target.coeffs[col] = sub(target.coeffs[col], mul(factor, srcRow.coeffs[col]));
|
||||
}
|
||||
// Eliminate data bytes
|
||||
for (let b = 0; b < this.symbolLength; b++) {
|
||||
target.data[b] = sub(target.data[b], mul(factor, srcRow.data[b]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Solve the system once full rank is achieved.
|
||||
* After this, the RHS data contains the source symbols directly
|
||||
* (since the matrix is in RREF = identity).
|
||||
*/
|
||||
private solve(): void {
|
||||
// Validate: rows should form an identity matrix in RREF
|
||||
// After incremental RREF maintenance, the first K columns should be identity
|
||||
// so the data already represents the source symbols.
|
||||
// But we verify and extract in order of pivot columns.
|
||||
|
||||
// Sort rows by pivot column to ensure correct ordering
|
||||
this.rows.sort((a, b) => {
|
||||
const pa = this.findPivot(a);
|
||||
const pb = this.findPivot(b);
|
||||
return pa - pb;
|
||||
});
|
||||
|
||||
// Update pivot mapping
|
||||
for (let col = 0; col < this.k; col++) {
|
||||
this.pivotForColumn[col] = -1;
|
||||
}
|
||||
for (let i = 0; i < this.rows.length; i++) {
|
||||
const p = this.findPivot(this.rows[i]);
|
||||
if (p >= 0) {
|
||||
this.pivotForColumn[p] = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify RREF structure: row i should have 1 at column i
|
||||
// Any deviation means we need to back-substitute
|
||||
const sourceSymbols: Uint8Array[] = new Array(this.k);
|
||||
|
||||
// In a proper RREF with rank K, the first K rows should be identity
|
||||
// But our incremental algorithm maintains this invariant,
|
||||
// so we can just extract data directly from each pivot row.
|
||||
for (let col = 0; col < this.k; col++) {
|
||||
const rowIdx = this.pivotForColumn[col];
|
||||
if (rowIdx < 0) {
|
||||
throw new Error(
|
||||
`RLNCDecoder: internal error — no pivot row for column ${col} despite rank=${this.k}`
|
||||
);
|
||||
}
|
||||
const row = this.rows[rowIdx];
|
||||
sourceSymbols[col] = new Uint8Array(row.data);
|
||||
}
|
||||
|
||||
this._sourceSymbols = sourceSymbols;
|
||||
this._solved = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-generation decoder that tracks received systematic and coded packets.
|
||||
*
|
||||
* Manages a mapping from generation index to RLNCDecoder instances.
|
||||
* Coefficient derivation matches the encoder (same PRNG seeded from
|
||||
* session ID, generation index, and coding seed).
|
||||
*/
|
||||
export class GenerationDecoder {
|
||||
/** Session identifier */
|
||||
private sessionId: number;
|
||||
/** Coding seed parameter */
|
||||
private codingSeed: number;
|
||||
/** Number of source symbols per generation */
|
||||
private k: number;
|
||||
/** Length of each symbol in bytes */
|
||||
private symbolLength: number;
|
||||
/** Map from generation index → RLNCDecoder */
|
||||
private decoders: Map<number, RLNCDecoder> = new Map();
|
||||
|
||||
constructor(
|
||||
k: number,
|
||||
symbolLength: number,
|
||||
sessionId: number,
|
||||
codingSeed: number
|
||||
) {
|
||||
this.k = k;
|
||||
this.symbolLength = symbolLength;
|
||||
this.sessionId = sessionId;
|
||||
this.codingSeed = codingSeed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a received symbol to a specific generation.
|
||||
*
|
||||
* @param generationIndex - The generation this symbol belongs to
|
||||
* @param symbol - The received symbol data bytes
|
||||
* @param coefficients - The coefficient vector for this symbol (length K)
|
||||
* @returns true if the symbol was accepted as linearly independent
|
||||
*/
|
||||
addSymbol(
|
||||
generationIndex: number,
|
||||
symbol: Uint8Array,
|
||||
coefficients: Uint8Array
|
||||
): boolean {
|
||||
const decoder = this.getOrCreateDecoder(generationIndex);
|
||||
|
||||
// Handle systematic symbols: coefficients should have a single 1
|
||||
// and the rest 0. The decoder's Gaussian elimination handles this
|
||||
// correctly regardless.
|
||||
return decoder.addSymbol(symbol, coefficients);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a systematic symbol (where only one coefficient is non-zero).
|
||||
* This is a convenience wrapper.
|
||||
*
|
||||
* @param generationIndex - The generation this symbol belongs to
|
||||
* @param symbol - The received symbol data bytes
|
||||
* @param sourceIndex - The index of the source symbol (0..K-1)
|
||||
* @returns true if the symbol was accepted
|
||||
*/
|
||||
addSystematicSymbol(
|
||||
generationIndex: number,
|
||||
symbol: Uint8Array,
|
||||
sourceIndex: number
|
||||
): boolean {
|
||||
const coeffs = new Uint8Array(this.k);
|
||||
coeffs[sourceIndex] = 1;
|
||||
return this.addSymbol(generationIndex, symbol, coeffs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a coded (non-systematic) symbol. The coefficient vector is
|
||||
* derived deterministically from the generation parameters and the
|
||||
* coding symbol index.
|
||||
*
|
||||
* @param generationIndex - The generation this symbol belongs to
|
||||
* @param symbol - The received coded symbol data bytes
|
||||
* @param codedSymbolIndex - Index among coded symbols (0..R-1)
|
||||
* @returns true if the symbol was accepted
|
||||
*/
|
||||
addCodedSymbol(
|
||||
generationIndex: number,
|
||||
symbol: Uint8Array,
|
||||
codedSymbolIndex: number
|
||||
): boolean {
|
||||
// Derive coefficient seed matching the encoder
|
||||
const baseSeed = deriveCoefficientSeed(
|
||||
this.sessionId,
|
||||
generationIndex,
|
||||
this.codingSeed
|
||||
);
|
||||
const symbolSeed = (baseSeed ^ ((codedSymbolIndex + 1) * 0x9e3779b9)) >>> 0;
|
||||
const coeffs = generateCoefficients(this.k, symbolSeed);
|
||||
|
||||
return this.addSymbol(generationIndex, symbol, coeffs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific generation has been fully decoded.
|
||||
*/
|
||||
isSolved(generationIndex: number): boolean {
|
||||
const decoder = this.decoders.get(generationIndex);
|
||||
return decoder !== undefined && decoder.isSolved();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the reconstructed source symbols for a generation.
|
||||
*
|
||||
* @param generationIndex - The generation to get symbols for
|
||||
* @returns Array of K source symbol byte arrays, or null if not yet solved
|
||||
*/
|
||||
getSourceSymbols(generationIndex: number): Uint8Array[] | null {
|
||||
const decoder = this.decoders.get(generationIndex);
|
||||
return decoder ? decoder.getSourceSymbols() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current rank for a generation.
|
||||
*/
|
||||
rank(generationIndex: number): number {
|
||||
const decoder = this.decoders.get(generationIndex);
|
||||
return decoder ? decoder.rank : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create an RLNCDecoder for a generation.
|
||||
*/
|
||||
private getOrCreateDecoder(generationIndex: number): RLNCDecoder {
|
||||
let decoder = this.decoders.get(generationIndex);
|
||||
if (!decoder) {
|
||||
decoder = new RLNCDecoder(
|
||||
this.k,
|
||||
this.symbolLength,
|
||||
this.sessionId,
|
||||
generationIndex,
|
||||
this.codingSeed
|
||||
);
|
||||
this.decoders.set(generationIndex, decoder);
|
||||
}
|
||||
return decoder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Systematic RLNC (Random Linear Network Coding) encoder per generation.
|
||||
*
|
||||
* Given K source symbols, generates R coded repair symbols where each
|
||||
* coded symbol is a linear combination of all source symbols with
|
||||
* coefficients drawn from GF(256). The coefficient vectors are
|
||||
* deterministically derived from (session_id, generation_index, coding_seed)
|
||||
* using the xoshiro128** PRNG.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { mul, add } from './gf256';
|
||||
import { Xoshiro128 } from './xoshiro';
|
||||
|
||||
/**
|
||||
* A coded symbol with its coefficient vector.
|
||||
* The coefficients define the linear combination of source symbols
|
||||
* that produced this symbol.
|
||||
*/
|
||||
export interface CodedSymbol {
|
||||
/** The coefficient vector (length K), each element in GF(256) */
|
||||
coefficients: Uint8Array;
|
||||
/** The coded symbol data (same length as source symbols) */
|
||||
data: Uint8Array;
|
||||
/** Whether this is a systematic (original) symbol */
|
||||
isSystematic: boolean;
|
||||
/** Original index if systematic, -1 if coded */
|
||||
sourceIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A source symbol — raw data bytes with its index in the generation.
|
||||
*/
|
||||
export interface SourceSymbol {
|
||||
index: number;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a deterministic 32-bit seed from session/generation/coding parameters.
|
||||
*
|
||||
* Uses a simple mixing function (xorshift32 on each component combined).
|
||||
*
|
||||
* @param sessionId - Session identifier (narrowed to 32 bits)
|
||||
* @param generationIndex - Index of this generation within the session
|
||||
* @param codingSeed - Extra coding seed parameter
|
||||
* @returns A 32-bit unsigned integer seed
|
||||
*/
|
||||
export function deriveCoefficientSeed(
|
||||
sessionId: number,
|
||||
generationIndex: number,
|
||||
codingSeed: number
|
||||
): number {
|
||||
// Mix the three components using xorshift32-style operations
|
||||
let seed = (sessionId >>> 0) ^ ((generationIndex << 13) | (generationIndex >>> 19));
|
||||
seed = (seed ^ (seed >> 7)) * 0x9e3779b9;
|
||||
seed = seed ^ (seed >> 17) ^ codingSeed;
|
||||
seed = (seed ^ (seed >> 5)) * 0x85ebca6b;
|
||||
seed = seed ^ (seed >> 13);
|
||||
return seed >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a non-zero coefficient vector of length K from a seed.
|
||||
*
|
||||
* Uses xoshiro128** seeded with the given seed to generate K random
|
||||
* bytes. If the resulting vector would be all-zero, re-rolls.
|
||||
*
|
||||
* @param k - Number of source symbols (length of coefficient vector)
|
||||
* @param seed - 32-bit seed for the PRNG
|
||||
* @returns A Uint8Array of length K with non-zero coefficients
|
||||
*/
|
||||
export function generateCoefficients(k: number, seed: number): Uint8Array {
|
||||
const rng = new Xoshiro128(seed);
|
||||
const coeffs = new Uint8Array(k);
|
||||
|
||||
let allZero = true;
|
||||
let attempts = 0;
|
||||
const MAX_ATTEMPTS = 100;
|
||||
|
||||
do {
|
||||
allZero = false;
|
||||
for (let i = 0; i < k; i++) {
|
||||
// Generate a random non-zero GF(256) element
|
||||
let v: number;
|
||||
do {
|
||||
v = rng.nextByte();
|
||||
} while (v === 0);
|
||||
coeffs[i] = v;
|
||||
}
|
||||
// Check if all zero (shouldn't happen given non-zero generation, but guard)
|
||||
allZero = true;
|
||||
for (let i = 0; i < k; i++) {
|
||||
if (coeffs[i] !== 0) {
|
||||
allZero = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
attempts++;
|
||||
if (attempts >= MAX_ATTEMPTS) {
|
||||
// Fallback: force at least one coefficient to 1
|
||||
coeffs[0] = 1;
|
||||
allZero = false;
|
||||
}
|
||||
} while (allZero);
|
||||
|
||||
return coeffs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a generation of K source symbols into K systematic + R coded symbols.
|
||||
*
|
||||
* The first K output symbols are the source symbols themselves (systematic
|
||||
* encoding). The remaining R symbols are random linear combinations of all
|
||||
* K source symbols over GF(256).
|
||||
*
|
||||
* @param sourceSymbols - Array of K source symbol data arrays (each Uint8Array of equal length)
|
||||
* @param k - Number of source symbols in the generation
|
||||
* @param r - Number of coded repair symbols to generate
|
||||
* @param sessionId - Session identifier for deterministic coefficient generation
|
||||
* @param generationIndex - Index of this generation within the session
|
||||
* @param codingSeed - Additional seed parameter for coefficient derivation
|
||||
* @returns Array of (k + r) CodedSymbols: k systematic followed by r coded
|
||||
* @throws {RangeError} If sourceSymbols length doesn't match k, or symbols have unequal lengths
|
||||
*/
|
||||
export function encodeGeneration(
|
||||
sourceSymbols: Uint8Array[],
|
||||
k: number,
|
||||
r: number,
|
||||
sessionId: number,
|
||||
generationIndex: number,
|
||||
codingSeed: number
|
||||
): CodedSymbol[] {
|
||||
// Validate inputs
|
||||
if (sourceSymbols.length !== k) {
|
||||
throw new RangeError(
|
||||
`encodeGeneration: expected ${k} source symbols, got ${sourceSymbols.length}`
|
||||
);
|
||||
}
|
||||
|
||||
if (k === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const symbolLength = sourceSymbols[0].length;
|
||||
for (let i = 1; i < k; i++) {
|
||||
if (sourceSymbols[i].length !== symbolLength) {
|
||||
throw new RangeError(
|
||||
`encodeGeneration: symbol at index ${i} has length ${sourceSymbols[i].length}, ` +
|
||||
`expected ${symbolLength}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const results: CodedSymbol[] = [];
|
||||
|
||||
// 1. Systematic symbols: output the source symbols directly
|
||||
for (let i = 0; i < k; i++) {
|
||||
const coeffs = new Uint8Array(k);
|
||||
coeffs[i] = 1; // Only coefficient i is non-zero (identity vector)
|
||||
|
||||
results.push({
|
||||
coefficients: coeffs,
|
||||
data: new Uint8Array(sourceSymbols[i]), // Copy to avoid mutation
|
||||
isSystematic: true,
|
||||
sourceIndex: i,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Coded repair symbols: random linear combinations
|
||||
for (let j = 0; j < r; j++) {
|
||||
// Derive a unique seed for this repair symbol
|
||||
// Mix the repair index into the seed to get distinct vectors
|
||||
const symbolSeed =
|
||||
deriveCoefficientSeed(sessionId, generationIndex, codingSeed) ^
|
||||
((j + 1) * 0x9e3779b9) >>> 0;
|
||||
|
||||
const coeffs = generateCoefficients(k, symbolSeed);
|
||||
|
||||
// Compute C = Σ coeff[i] * sourceSymbols[i] over GF(256)
|
||||
const codedData = new Uint8Array(symbolLength);
|
||||
|
||||
for (let i = 0; i < k; i++) {
|
||||
const coeff = coeffs[i];
|
||||
if (coeff === 0) continue;
|
||||
|
||||
// For each byte in the symbol: codedData[byte] += coeff * source[i][byte]
|
||||
const src = sourceSymbols[i];
|
||||
for (let b = 0; b < symbolLength; b++) {
|
||||
codedData[b] ^= mul(coeff, src[b]); // mul then XOR (addition in GF(256))
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
coefficients: coeffs,
|
||||
data: codedData,
|
||||
isSystematic: false,
|
||||
sourceIndex: -1,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* xoshiro128** PRNG implementation.
|
||||
*
|
||||
* A 32-bit seed is expanded to a 128-bit state via splitmix64,
|
||||
* then the xoshiro128** algorithm generates high-quality 32-bit
|
||||
* unsigned integers. Used for deterministic coefficient generation
|
||||
* in the RLNC encoder/decoder.
|
||||
*
|
||||
* Reference: https://prng.di.unimi.it/xoshiro128starstar.c
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** Internal 128-bit state as four 32-bit unsigned integers. */
|
||||
export class Xoshiro128 {
|
||||
private s: Uint32Array;
|
||||
|
||||
/**
|
||||
* Create a new xoshiro128** instance from a 32-bit seed.
|
||||
* The seed is expanded to a 128-bit state using splitmix64.
|
||||
*
|
||||
* @param seed - 32-bit integer seed
|
||||
*/
|
||||
constructor(seed: number) {
|
||||
// Normalize to unsigned 32-bit
|
||||
const s = ((seed >>> 0) | 0) >>> 0;
|
||||
this.s = new Uint32Array(4);
|
||||
// Use splitmix64 to expand the 32-bit seed into 4 × 32-bit state words
|
||||
// splitmix64 generates two 64-bit outputs from a 64-bit state, but we
|
||||
// only have a 32-bit seed; we treat it as the initial splitmix64 state.
|
||||
let state: bigint = BigInt(s);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
state = splitmix64Next(state);
|
||||
// Take lower 32 bits of each 64-bit output
|
||||
this.s[i] = Number(state & BigInt(0xffffffff)) >>> 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the next 32-bit unsigned integer.
|
||||
* Uses the xoshiro128** algorithm.
|
||||
*
|
||||
* @returns A pseudo-random 32-bit unsigned integer (0..2^32-1)
|
||||
*/
|
||||
next(): number {
|
||||
const result = xoshiro128StarStar(this.s);
|
||||
// Advance state
|
||||
const t = this.s[1] << 9;
|
||||
|
||||
this.s[2] ^= this.s[0];
|
||||
this.s[3] ^= this.s[1];
|
||||
this.s[1] ^= this.s[2];
|
||||
this.s[0] ^= this.s[3];
|
||||
|
||||
this.s[2] ^= t;
|
||||
|
||||
// Rotate state[3] left by 11
|
||||
this.s[3] = rotl(this.s[3], 11);
|
||||
|
||||
return result >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a random byte (0..255) from the current state.
|
||||
*/
|
||||
nextByte(): number {
|
||||
return this.next() & 0xff;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Splitmix64 step: advance state and return 64-bit output.
|
||||
*/
|
||||
function splitmix64Next(state: bigint): bigint {
|
||||
state = (state + BigInt(0x9e3779b97f4a7c15)) & BigInt('0xffffffffffffffff');
|
||||
let z = state;
|
||||
z = (z ^ (z >> 30n)) * BigInt(0xbf58476d1ce4e5b9);
|
||||
z = (z ^ (z >> 27n)) & BigInt('0xffffffffffffffff');
|
||||
z = (z * BigInt(0x94d049bb133111eb)) & BigInt('0xffffffffffffffff');
|
||||
z = z ^ (z >> 31n);
|
||||
return z & BigInt('0xffffffffffffffff');
|
||||
}
|
||||
|
||||
/**
|
||||
* xoshiro128** scrambler: s[0] * 5, rotate left 7, * 9
|
||||
*/
|
||||
function xoshiro128StarStar(s: Uint32Array): number {
|
||||
const result = Math.imul(
|
||||
rotl(Math.imul(s[1] >>> 0, 5) >>> 0, 7) >>> 0,
|
||||
9
|
||||
) >>> 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate a 32-bit unsigned integer left by k bits.
|
||||
*/
|
||||
function rotl(x: number, k: number): number {
|
||||
return ((x << k) | (x >>> (32 - k))) >>> 0;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Animated GIF generation for QR-frame sequences.
|
||||
*
|
||||
* Uses the `gifenc` library to assemble indexed-colour GIFs from raw RGBA
|
||||
* frames produced by the frame rasterizer. Each frame is a full image
|
||||
* (not partial deltas) with a 2-colour global palette (white, black).
|
||||
* The loop count is set to infinity via the NETSCAPE 2.0 extension.
|
||||
*/
|
||||
|
||||
import { GIFEncoder } from 'gifenc';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 2-colour palette: white → index 0, black → index 1 */
|
||||
const PALETTE: number[][] = [
|
||||
[255, 255, 255], // white (background / quiet zone)
|
||||
[0, 0, 0 ], // black (QR modules)
|
||||
];
|
||||
|
||||
/** Default inter-frame delay in milliseconds (100 ms = 10 fps) */
|
||||
const DEFAULT_DELAY_MS = 100;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RGBA → indexed conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert an RGBA pixel buffer into a palette-indexed buffer.
|
||||
*
|
||||
* Pixels are classified as black (index 1) if their RGB value is darker
|
||||
* than the mid-point threshold; everything else becomes white (index 0).
|
||||
* The alpha channel is ignored for classification.
|
||||
*
|
||||
* @param rgba Flat RGBA data (4 bytes per pixel)
|
||||
* @returns Uint8Array where each byte is 0 (white) or 1 (black)
|
||||
*/
|
||||
function rgbaToIndexed(rgba: Uint8Array): Uint8Array {
|
||||
const pixelCount = rgba.length / 4;
|
||||
const indexed = new Uint8Array(pixelCount);
|
||||
|
||||
for (let i = 0; i < pixelCount; i++) {
|
||||
const off = i * 4;
|
||||
// Sum R+G+B to determine brightness (0 = black, 765 = white)
|
||||
const brightness = rgba[off]! + rgba[off + 1]! + rgba[off + 2]!;
|
||||
// Mid-point threshold at 50 % (382.5)
|
||||
indexed[i] = brightness < 384 ? 1 : 0;
|
||||
}
|
||||
|
||||
return indexed;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GIF creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build an animated GIF from a sequence of QR-code frames.
|
||||
*
|
||||
* @param frames Array of RGBA pixel buffers (from `rasterizeQR().data`)
|
||||
* @param delays Per-frame delay in **milliseconds** (default: 100 ms).
|
||||
* If a single value is passed, it is used for all frames.
|
||||
* @param width Frame width in pixels (must be uniform across all frames)
|
||||
* @param height Frame height in pixels
|
||||
* @returns Complete GIF file as a `Uint8Array`
|
||||
*/
|
||||
type PixelData = Uint8Array | Uint8ClampedArray;
|
||||
|
||||
export function createQRGif(
|
||||
frames: PixelData[],
|
||||
delays: number | number[] = DEFAULT_DELAY_MS,
|
||||
width: number,
|
||||
height: number,
|
||||
): Uint8Array {
|
||||
if (frames.length === 0) {
|
||||
throw new Error('At least one frame is required');
|
||||
}
|
||||
|
||||
// Normalise delays
|
||||
const delayArr: number[] =
|
||||
typeof delays === 'number'
|
||||
? new Array(frames.length).fill(delays)
|
||||
: delays;
|
||||
|
||||
if (delayArr.length !== frames.length) {
|
||||
throw new Error(
|
||||
`Delay array length (${delayArr.length}) must match frame count (${frames.length})`,
|
||||
);
|
||||
}
|
||||
|
||||
const encoder = GIFEncoder({ auto: true });
|
||||
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
const indexed = rgbaToIndexed(new Uint8Array(frames[i]!.buffer, frames[i]!.byteOffset, frames[i]!.byteLength));
|
||||
const isFirst = i === 0;
|
||||
|
||||
encoder.writeFrame(indexed, width, height, {
|
||||
palette: isFirst ? PALETTE : undefined,
|
||||
delay: delayArr[i]!,
|
||||
repeat: isFirst ? 0 : undefined, // 0 = loop forever (NETSCAPE)
|
||||
});
|
||||
}
|
||||
|
||||
encoder.finish();
|
||||
return encoder.bytes();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Size estimation heuristic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Estimate the size (in bytes) of an animated QR GIF.
|
||||
*
|
||||
* Heuristic formula (≈15 % of raw RGBA size + fixed overhead):
|
||||
* size ≈ rawSize × 0.15 + 150 × frameCount
|
||||
*
|
||||
* The compression ratio for pure black-and-white QR images with LZW in a
|
||||
* 2-colour palette is very high (often > 10:1).
|
||||
*
|
||||
* @param rawSize Total RGBA data size in bytes (width × height × 4 × frames)
|
||||
* @param profile Profile identifier (e.g. "V31-Q", "V35-M", "V40-M"),
|
||||
* used for potential future tuning; currently unused.
|
||||
* @returns Estimated GIF file size in bytes
|
||||
*/
|
||||
export function estimateGifSize(rawSize: number, _profile: string): number {
|
||||
// Derive frame count and dimensions from rawSize (approximate)
|
||||
// Assume roughly scale=3 → moduleCount = dimension/3 - 8
|
||||
// This is a rough heuristic, so we keep it simple.
|
||||
const compressedData = Math.round(rawSize * 0.15);
|
||||
const overheadPerFrame = 150; // header + GCE + image descriptor + LZW tables
|
||||
const frameCount = Math.max(1, Math.round(rawSize / (250_000))); // rough guess
|
||||
|
||||
return compressedData + overheadPerFrame * frameCount + 32; // trailer
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Compression utilities using deflate-raw.
|
||||
*
|
||||
* Uses the native CompressionStream API (available in modern browsers,
|
||||
* Bun, and Deno) when available, with a fallback to the fflate library
|
||||
* for environments without native support.
|
||||
*
|
||||
* Note: fflate's `deflate` / `inflate` emit raw DEFLATE (RFC 1951),
|
||||
* which is the same format produced by `CompressionStream('deflate-raw')`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { deflate as fflateDeflate, inflate as fflateInflate } from 'fflate';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Drain a readable stream into a single Uint8Array.
|
||||
*/
|
||||
async function drainStream(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
): Promise<Uint8Array> {
|
||||
const chunks: Uint8Array[] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
}
|
||||
const totalLen = chunks.reduce((a, c) => a + c.length, 0);
|
||||
const result = new Uint8Array(totalLen);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Compression ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compress data using deflate-raw (RFC 1951).
|
||||
*
|
||||
* Uses the native `CompressionStream` API when available, falling back
|
||||
* to fflate's `deflate` for environments without it.
|
||||
*
|
||||
* @param data - Raw uncompressed bytes
|
||||
* @returns Deflate-raw compressed bytes
|
||||
*/
|
||||
export async function compress(data: Uint8Array): Promise<Uint8Array> {
|
||||
// Check for native CompressionStream API
|
||||
if (
|
||||
typeof CompressionStream !== 'undefined' &&
|
||||
typeof CompressionStream === 'function'
|
||||
) {
|
||||
const cs = new CompressionStream('deflate-raw');
|
||||
const writer = cs.writable.getWriter();
|
||||
// Re-create from ArrayBuffer to satisfy TS 5.7+ stricter BufferSource types
|
||||
await writer.write(new Uint8Array(data.buffer as ArrayBuffer, data.byteOffset, data.byteLength));
|
||||
await writer.close();
|
||||
const reader = cs.readable.getReader();
|
||||
return drainStream(reader);
|
||||
}
|
||||
|
||||
// Fallback: fflate deflate (raw DEFLATE, same format)
|
||||
return new Promise((resolve, reject) => {
|
||||
fflateDeflate(data, (err: Error | null, result: Uint8Array) => {
|
||||
if (err) reject(err);
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Decompression ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Decompress data that was compressed with deflate-raw.
|
||||
*
|
||||
* Uses the native `DecompressionStream` API when available, falling back
|
||||
* to fflate's `inflate`.
|
||||
*
|
||||
* @param data - Deflate-raw compressed bytes
|
||||
* @param _originalSize - Original uncompressed size (reserved for future
|
||||
* pre-allocation hints; currently unused)
|
||||
* @returns Decompressed bytes
|
||||
*/
|
||||
export async function decompress(
|
||||
data: Uint8Array,
|
||||
_originalSize: number,
|
||||
): Promise<Uint8Array> {
|
||||
// Check for native DecompressionStream API
|
||||
if (
|
||||
typeof DecompressionStream !== 'undefined' &&
|
||||
typeof DecompressionStream === 'function'
|
||||
) {
|
||||
const ds = new DecompressionStream('deflate-raw');
|
||||
const writer = ds.writable.getWriter();
|
||||
// Re-create from ArrayBuffer to satisfy TS 5.7+ stricter BufferSource types
|
||||
await writer.write(new Uint8Array(data.buffer as ArrayBuffer, data.byteOffset, data.byteLength));
|
||||
await writer.close();
|
||||
const reader = ds.readable.getReader();
|
||||
return drainStream(reader);
|
||||
}
|
||||
|
||||
// Fallback: fflate inflate (raw DEFLATE, same format)
|
||||
return new Promise((resolve, reject) => {
|
||||
fflateInflate(data, (err: Error | null, result: Uint8Array) => {
|
||||
if (err) reject(err);
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Compression Heuristic ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Determine whether the compression is worthwhile.
|
||||
*
|
||||
* Returns true if the compressed representation is at least 3 % smaller
|
||||
* than the original, indicating meaningful space savings.
|
||||
*
|
||||
* @param original - Original uncompressed bytes
|
||||
* @param compressed - Compressed bytes
|
||||
* @returns `true` if compression saves >= 3 %
|
||||
*/
|
||||
export function shouldCompress(
|
||||
original: Uint8Array,
|
||||
compressed: Uint8Array,
|
||||
): boolean {
|
||||
if (original.length === 0) return false;
|
||||
const saving = 1 - compressed.length / original.length;
|
||||
return saving >= 0.03;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* SHA-256 hashing utilities using the Web Crypto API.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* Compute the SHA-256 digest of arbitrary data.
|
||||
*
|
||||
* Uses `crypto.subtle.digest` which is available in modern browsers,
|
||||
* Bun, Deno, and Node.js (via the Web Crypto API).
|
||||
*
|
||||
* @param data - Input bytes to hash
|
||||
* @returns 32-byte SHA-256 digest
|
||||
*/
|
||||
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
||||
const hashBuffer = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
data as BufferSource,
|
||||
);
|
||||
return new Uint8Array(hashBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the hex-encoded SHA-256 digest of arbitrary data.
|
||||
*
|
||||
* @param data - Input bytes to hash
|
||||
* @returns Lower-case hex string of the SHA-256 digest (64 characters)
|
||||
*/
|
||||
export async function sha256Hex(data: Uint8Array): Promise<string> {
|
||||
const hash = await sha256(data);
|
||||
const hexParts: string[] = new Array(hash.length);
|
||||
for (let i = 0; i < hash.length; i++) {
|
||||
hexParts[i] = hash[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
return hexParts.join('');
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Core protocol constants, enums, and profile definitions for the
|
||||
* QR-over-GIF transfer system.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
// ─── Magic Bytes & Version ───────────────────────────────────────────────────
|
||||
|
||||
/** Magic identifier bytes: 'QG' (0x51, 0x47). */
|
||||
export const MAGIC_BYTES = new Uint8Array([0x51, 0x47]);
|
||||
|
||||
/** Current protocol version. */
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
|
||||
// ─── Packet Geometry ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Size of the fixed packet header in bytes (offsets 0–27). */
|
||||
export const HEADER_SIZE = 28;
|
||||
|
||||
/** Size of the CRC32C trailer in bytes. */
|
||||
export const CRC32C_SIZE = 4;
|
||||
|
||||
/** Total overhead per packet: header + CRC32C (28 + 4 = 32). */
|
||||
export const PACKET_OVERHEAD = HEADER_SIZE + CRC32C_SIZE;
|
||||
|
||||
// ─── Packet Type Enum ────────────────────────────────────────────────────────
|
||||
|
||||
/** Packet type identifiers. */
|
||||
export enum PacketType {
|
||||
/** Manifest / metadata packet. */
|
||||
MANIFEST = 0,
|
||||
/** Systematic (uncoded) data symbol. */
|
||||
DATA_SYSTEMATIC = 1,
|
||||
/** Fountain-coded (repaired) data symbol. */
|
||||
DATA_CODED = 2,
|
||||
}
|
||||
|
||||
// ─── Flag Bits ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Flag bit masks for the packet flags byte. */
|
||||
export enum Flags {
|
||||
/** No flags set. */
|
||||
NONE = 0,
|
||||
/** Marks the last symbol in a generation. */
|
||||
LAST_SYMBOL_IN_GENERATION = 1 << 0,
|
||||
/** Payload contains padding bytes at the end. */
|
||||
PAYLOAD_PADDED = 1 << 1,
|
||||
/** Manifest is critical / first fragment. */
|
||||
MANIFEST_CRITICAL = 1 << 2,
|
||||
}
|
||||
|
||||
// ─── Profile IDs ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** QR profile identifiers. */
|
||||
export enum ProfileId {
|
||||
/** Robust profile: QR V31, ECC Q, K=24, R=12. */
|
||||
ROBUST = 0,
|
||||
/** Balanced profile: QR V35, ECC M, K=24, R=8. */
|
||||
BALANCED = 1,
|
||||
/** Fast profile: QR V40, ECC M, K=32, R=8. */
|
||||
FAST = 2,
|
||||
}
|
||||
|
||||
// ─── Profile Config ──────────────────────────────────────────────────────────
|
||||
|
||||
/** ECC level type for QR code generation. */
|
||||
export type EccLevel = 'L' | 'M' | 'Q' | 'H';
|
||||
|
||||
/** Configuration for a QR transfer profile. */
|
||||
export interface ProfileConfig {
|
||||
/** QR code version (1–40). */
|
||||
qrVersion: number;
|
||||
/** Error correction level. */
|
||||
eccLevel: EccLevel;
|
||||
/** Number of source symbols per generation. */
|
||||
k: number;
|
||||
/** Number of coded (repaired) symbols per generation. */
|
||||
r: number;
|
||||
/** Inter-frame delay in centiseconds (cs). */
|
||||
frameDelay: number;
|
||||
/** Approximate maximum payload per packet in bytes. */
|
||||
maxPacketPayload: number;
|
||||
}
|
||||
|
||||
/** Lookup of all defined profiles by their ProfileId. */
|
||||
export const PROFILES: Record<ProfileId, ProfileConfig> = {
|
||||
[ProfileId.ROBUST]: {
|
||||
qrVersion: 31,
|
||||
eccLevel: 'Q',
|
||||
k: 24,
|
||||
r: 12,
|
||||
frameDelay: 30,
|
||||
maxPacketPayload: 1230,
|
||||
},
|
||||
[ProfileId.BALANCED]: {
|
||||
qrVersion: 35,
|
||||
eccLevel: 'M',
|
||||
k: 24,
|
||||
r: 8,
|
||||
frameDelay: 20,
|
||||
maxPacketPayload: 1770,
|
||||
},
|
||||
[ProfileId.FAST]: {
|
||||
qrVersion: 40,
|
||||
eccLevel: 'M',
|
||||
k: 32,
|
||||
r: 8,
|
||||
frameDelay: 15,
|
||||
maxPacketPayload: 2290,
|
||||
},
|
||||
};
|
||||
|
||||
/** Default profile (Robust, for robustness-priority). */
|
||||
export const DEFAULT_PROFILE_ID = ProfileId.ROBUST;
|
||||
|
||||
/**
|
||||
* Generate a random 64-bit session identifier.
|
||||
*
|
||||
* Uses `crypto.getRandomValues` to produce 8 cryptographically
|
||||
* random bytes, then interprets them as a little-endian unsigned
|
||||
* 64-bit bigint.
|
||||
*
|
||||
* @returns A random 64-bit session ID
|
||||
*/
|
||||
export function createSessionId(): bigint {
|
||||
const buf = new Uint8Array(8);
|
||||
crypto.getRandomValues(buf);
|
||||
let val = 0n;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
val |= BigInt(buf[i]) << BigInt(i * 8);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* CRC32C (Castagnoli) implementation for packet integrity.
|
||||
*
|
||||
* Uses polynomial 0x82F63B78 (reflected form of 0x1EDC6F41)
|
||||
* with a pre-computed 256-entry lookup table.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** Pre-computed CRC32C lookup table (256 entries). */
|
||||
const TABLE = new Uint32Array(256);
|
||||
|
||||
/** Whether the lookup table has been built. */
|
||||
let tableBuilt = false;
|
||||
|
||||
/**
|
||||
* Build the CRC32C lookup table using the reflected polynomial.
|
||||
*
|
||||
* Polynomial: 0x82F63B78 (Castagnoli, reflected)
|
||||
* Each table entry is computed by shifting the index byte and
|
||||
* XOR-ing with the polynomial when the LSB is set.
|
||||
*/
|
||||
function buildTable(): void {
|
||||
const poly = 0x82f63b78 >>> 0;
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let crc = i;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
if (crc & 1) {
|
||||
crc = (crc >>> 1) ^ poly;
|
||||
} else {
|
||||
crc >>>= 1;
|
||||
}
|
||||
}
|
||||
TABLE[i] = crc >>> 0;
|
||||
}
|
||||
tableBuilt = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the lookup table has been initialized.
|
||||
* Called lazily on first CRC computation.
|
||||
*/
|
||||
function ensureTable(): void {
|
||||
if (!tableBuilt) {
|
||||
buildTable();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the CRC32C checksum over a byte array.
|
||||
*
|
||||
* Uses the reflected Castagnoli polynomial (0x82F63B78) with
|
||||
* the standard CRC algorithm: initialize to 0xFFFFFFFF, process
|
||||
* each byte through the lookup table, and XOR the final result
|
||||
* with 0xFFFFFFFF.
|
||||
*
|
||||
* @param data - Input bytes
|
||||
* @param initial - Initial CRC value (for chunked computation, default 0)
|
||||
* @returns The 32-bit CRC32C value (unsigned)
|
||||
*/
|
||||
export function crc32c(data: Uint8Array, initial: number = 0): number {
|
||||
ensureTable();
|
||||
let crc = (initial ^ 0xffffffff) >>> 0;
|
||||
const len = data.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const idx = (crc ^ data[i]) & 0xff;
|
||||
crc = (TABLE[idx] ^ (crc >>> 8)) >>> 0;
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a CRC32C computation.
|
||||
*
|
||||
* @returns Initial CRC state (0xFFFFFFFF)
|
||||
*/
|
||||
export function crc32cInit(): number {
|
||||
ensureTable();
|
||||
return 0xffffffff >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an in-progress CRC32C computation with additional data.
|
||||
*
|
||||
* @param crc - Current CRC state
|
||||
* @param data - Additional bytes to incorporate
|
||||
* @returns Updated CRC state
|
||||
*/
|
||||
export function crc32cUpdate(crc: number, data: Uint8Array): number {
|
||||
ensureTable();
|
||||
let state = crc >>> 0;
|
||||
const len = data.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const idx = (state ^ data[i]) & 0xff;
|
||||
state = (TABLE[idx] ^ (state >>> 8)) >>> 0;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a CRC32C computation.
|
||||
*
|
||||
* @param crc - Final CRC state
|
||||
* @returns The completed CRC32C value
|
||||
*/
|
||||
export function crc32cFinal(crc: number): number {
|
||||
return ((crc >>> 0) ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Manifest schema, serialization, and fragmentation support.
|
||||
*
|
||||
* The manifest carries session-level metadata encoded in CBOR (via the
|
||||
* `cbor-x` library). If the serialized manifest exceeds a single packet's
|
||||
* payload capacity it is fragmented across multiple MANIFEST-type packets.
|
||||
*
|
||||
* Fragments use the following packet header conventions:
|
||||
* - `generation_index` = 0 (reserved for manifest)
|
||||
* - `symbol_index` = fragment ordinal (0, 1, 2, …)
|
||||
* - `generation_k` = total number of manifest fragments
|
||||
* - `flags` = MANIFEST_CRITICAL on first fragment,
|
||||
* LAST_SYMBOL_IN_GENERATION on last fragment
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { encode, decode } from 'cbor-x';
|
||||
import {
|
||||
PacketType,
|
||||
Flags,
|
||||
ProfileId,
|
||||
PROTOCOL_VERSION,
|
||||
HEADER_SIZE,
|
||||
CRC32C_SIZE,
|
||||
} from './constants';
|
||||
import { PacketHeader, createPacket, parsePacket } from './packet';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Content kind conveyed by this session. */
|
||||
export type ContentKind = 'text' | 'file';
|
||||
|
||||
/** Compression codec identifier. */
|
||||
export type CompressionCodec = 'none' | 'deflate-raw';
|
||||
|
||||
/** Decoded manifest metadata. */
|
||||
export interface ManifestData {
|
||||
/** Protocol version. */
|
||||
protocolVersion: number;
|
||||
/** Application version string. */
|
||||
appVersion: string;
|
||||
/** Unique 64-bit session identifier. */
|
||||
sessionId: bigint;
|
||||
/** Original file name (empty for text). */
|
||||
originalFilename: string;
|
||||
/** MIME type of the content. */
|
||||
mimeType: string;
|
||||
/** Kind of content: 'text' or 'file'. */
|
||||
contentKind: ContentKind;
|
||||
/** Size of the original data in bytes. */
|
||||
originalSize: number;
|
||||
/** Size after preprocessing (before framing) in bytes. */
|
||||
preprocessedSize: number;
|
||||
/** Compression codec applied during preprocessing. */
|
||||
compressionCodec: CompressionCodec;
|
||||
/** SHA-256 hex digest of the original data. */
|
||||
originalSha256: string;
|
||||
/** QR profile identifier used for this session. */
|
||||
qrProfile: ProfileId;
|
||||
/** Packet payload size in bytes. */
|
||||
packetPayloadSize: number;
|
||||
/** Number of source symbols per generation (K). */
|
||||
generationK: number;
|
||||
/** Number of coded symbols generated per generation (R). */
|
||||
codedPerGen: number;
|
||||
/** Total number of generations in the session. */
|
||||
totalGenerations: number;
|
||||
/** Actual size (in symbols) of the last generation. */
|
||||
lastGenRealSize: number;
|
||||
/** GIF frame delay in centiseconds. */
|
||||
gifFrameDelay: number;
|
||||
/** Loop parameters (0 = loop forever, >0 = repeat count). */
|
||||
loopParams: number;
|
||||
}
|
||||
|
||||
// ─── CBOR Field Names (short keys for compactness) ──────────────────────────
|
||||
|
||||
interface ManifestEncoded {
|
||||
pv: number; // protocolVersion
|
||||
av: string; // appVersion
|
||||
si: string; // sessionId (as decimal string for CBOR safety)
|
||||
of: string; // originalFilename
|
||||
mt: string; // mimeType
|
||||
ck: ContentKind; // contentKind
|
||||
os: number; // originalSize
|
||||
ps: number; // preprocessedSize
|
||||
cc: CompressionCodec; // compressionCodec
|
||||
oh: string; // originalSha256
|
||||
qp: number; // qrProfile
|
||||
pp: number; // packetPayloadSize
|
||||
gk: number; // generationK
|
||||
cg: number; // codedPerGen
|
||||
tg: number; // totalGenerations
|
||||
lr: number; // lastGenRealSize
|
||||
fd: number; // gifFrameDelay
|
||||
lp: number; // loopParams
|
||||
}
|
||||
|
||||
// ─── Serialization ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serialize a ManifestData structure into CBOR-encoded bytes.
|
||||
*
|
||||
* Field names are shortened to 2-letter keys for compactness.
|
||||
* The sessionId (bigint) is stored as a decimal string so it
|
||||
* round-trips safely through any CBOR decoder.
|
||||
*
|
||||
* @param manifest - The manifest data to encode
|
||||
* @returns CBOR-encoded Uint8Array
|
||||
*/
|
||||
export function encodeManifest(manifest: ManifestData): Uint8Array {
|
||||
const obj: ManifestEncoded = {
|
||||
pv: manifest.protocolVersion,
|
||||
av: manifest.appVersion,
|
||||
si: manifest.sessionId.toString(),
|
||||
of: manifest.originalFilename,
|
||||
mt: manifest.mimeType,
|
||||
ck: manifest.contentKind,
|
||||
os: manifest.originalSize,
|
||||
ps: manifest.preprocessedSize,
|
||||
cc: manifest.compressionCodec,
|
||||
oh: manifest.originalSha256,
|
||||
qp: manifest.qrProfile,
|
||||
pp: manifest.packetPayloadSize,
|
||||
gk: manifest.generationK,
|
||||
cg: manifest.codedPerGen,
|
||||
tg: manifest.totalGenerations,
|
||||
lr: manifest.lastGenRealSize,
|
||||
fd: manifest.gifFrameDelay,
|
||||
lp: manifest.loopParams,
|
||||
};
|
||||
return encode(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize CBOR-encoded bytes into a ManifestData structure.
|
||||
*
|
||||
* @param data - CBOR-encoded manifest bytes
|
||||
* @returns The decoded manifest data
|
||||
*/
|
||||
export function decodeManifest(data: Uint8Array): ManifestData {
|
||||
const obj = decode(data) as ManifestEncoded;
|
||||
return {
|
||||
protocolVersion: obj.pv,
|
||||
appVersion: obj.av,
|
||||
sessionId: BigInt(obj.si),
|
||||
originalFilename: obj.of,
|
||||
mimeType: obj.mt,
|
||||
contentKind: obj.ck,
|
||||
originalSize: obj.os,
|
||||
preprocessedSize: obj.ps,
|
||||
compressionCodec: obj.cc,
|
||||
originalSha256: obj.oh,
|
||||
qrProfile: obj.qp as ProfileId,
|
||||
packetPayloadSize: obj.pp,
|
||||
generationK: obj.gk,
|
||||
codedPerGen: obj.cg,
|
||||
totalGenerations: obj.tg,
|
||||
lastGenRealSize: obj.lr,
|
||||
gifFrameDelay: obj.fd,
|
||||
loopParams: obj.lp,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Fragmentation ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a single MANIFEST-type packet for one fragment of the manifest.
|
||||
*
|
||||
* @param manifest - The full manifest (used for header fields)
|
||||
* @param fragmentData - This fragment's CBOR byte slice
|
||||
* @param fragmentIndex - Zero-based fragment index
|
||||
* @param totalFragments - Total number of fragments
|
||||
* @returns A complete transport packet (ready for QR encoding)
|
||||
*/
|
||||
export function createManifestPacket(
|
||||
manifest: ManifestData,
|
||||
fragmentData: Uint8Array,
|
||||
fragmentIndex: number,
|
||||
totalFragments: number,
|
||||
): Uint8Array {
|
||||
const isFirst = fragmentIndex === 0;
|
||||
const isLast = fragmentIndex === totalFragments - 1;
|
||||
|
||||
let flags = 0;
|
||||
if (isFirst) flags |= Flags.MANIFEST_CRITICAL;
|
||||
if (isLast) flags |= Flags.LAST_SYMBOL_IN_GENERATION;
|
||||
|
||||
const header: PacketHeader = {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
packetType: PacketType.MANIFEST,
|
||||
flags,
|
||||
profileId: manifest.qrProfile,
|
||||
sessionId: manifest.sessionId,
|
||||
generationIndex: 0, // Manifest uses generation index 0
|
||||
symbolIndex: fragmentIndex,
|
||||
generationK: totalFragments,
|
||||
payloadLength: fragmentData.length,
|
||||
codingSeed: 0, // Not used for manifest packets
|
||||
};
|
||||
|
||||
return createPacket(header, fragmentData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragment a serialized manifest into multiple transport packets.
|
||||
*
|
||||
* Each fragment's payload fits within `maxPayloadSize` bytes, and the
|
||||
* fragment metadata (fragment index, total count) is carried in the
|
||||
* packet header fields (`symbol_index`, `generation_k`).
|
||||
*
|
||||
* @param manifest - The manifest to fragment
|
||||
* @param maxPayloadSize - Maximum payload per packet (typically the
|
||||
* profile's maxPacketPayload)
|
||||
* @returns An array of complete transport packets
|
||||
*/
|
||||
export function fragmentManifest(
|
||||
manifest: ManifestData,
|
||||
maxPayloadSize: number,
|
||||
): Uint8Array[] {
|
||||
const encoded = encodeManifest(manifest);
|
||||
const totalFragments = Math.max(
|
||||
1,
|
||||
Math.ceil(encoded.length / maxPayloadSize),
|
||||
);
|
||||
const fragments: Uint8Array[] = [];
|
||||
|
||||
for (let i = 0; i < totalFragments; i++) {
|
||||
const start = i * maxPayloadSize;
|
||||
const end = Math.min(start + maxPayloadSize, encoded.length);
|
||||
const chunk = encoded.slice(start, end);
|
||||
fragments.push(createManifestPacket(manifest, chunk, i, totalFragments));
|
||||
}
|
||||
|
||||
return fragments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassemble and decode a manifest from its fragmented transport packets.
|
||||
*
|
||||
* Accepts an array of raw packet bytes, parses them, sorts by
|
||||
* `symbolIndex`, concatenates the payloads in order, and decodes
|
||||
* the CBOR manifest.
|
||||
*
|
||||
* @param packetBytes - Array of raw MANIFEST transport packets
|
||||
* @returns The reassembled and decoded manifest
|
||||
* @throws {Error} If no packets are provided or reassembly fails
|
||||
*/
|
||||
export function defragmentManifest(packetBytes: Uint8Array[]): ManifestData {
|
||||
if (packetBytes.length === 0) {
|
||||
throw new Error('No manifest packets to defragment');
|
||||
}
|
||||
|
||||
// Parse and sort by symbol index
|
||||
const parsed = packetBytes.map((pb) => parsePacket(pb));
|
||||
parsed.sort((a, b) => a.header.symbolIndex - b.header.symbolIndex);
|
||||
|
||||
// Concatenate payloads in order
|
||||
const totalSize = parsed.reduce((sum, p) => sum + p.payload.length, 0);
|
||||
const combined = new Uint8Array(totalSize);
|
||||
let offset = 0;
|
||||
for (const p of parsed) {
|
||||
combined.set(p.payload, offset);
|
||||
offset += p.payload.length;
|
||||
}
|
||||
|
||||
return decodeManifest(combined);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Transport packet serialization and deserialization.
|
||||
*
|
||||
* Packet format (all multi-byte fields are little-endian):
|
||||
*
|
||||
* | Offset | Size | Field | Description |
|
||||
* |--------|------|--------------------|--------------------------------------|
|
||||
* | 0 | 2 | magic | 'QG' (0x51, 0x47) |
|
||||
* | 2 | 1 | protocol_version | 1 |
|
||||
* | 3 | 1 | packet_type | 0=MANIFEST, 1=DATA_SYSTEMATIC, 2=CODED |
|
||||
* | 4 | 1 | flags | bit flags |
|
||||
* | 5 | 1 | profile_id | 0=Robust, 1=Balanced, 2=Fast |
|
||||
* | 6 | 8 | session_id | random 64-bit |
|
||||
* | 14 | 4 | generation_index | 32-bit |
|
||||
* | 18 | 2 | symbol_index | 16-bit |
|
||||
* | 20 | 2 | generation_k | number of source symbols |
|
||||
* | 22 | 2 | payload_length | 16-bit |
|
||||
* | 24 | 4 | coding_seed | 0 for systematic |
|
||||
* | 28 | N | payload | variable-length payload |
|
||||
* | 28+N | 4 | packet_crc32c | CRC32C over bytes 0–27 + payload |
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import {
|
||||
MAGIC_BYTES,
|
||||
PROTOCOL_VERSION,
|
||||
HEADER_SIZE,
|
||||
CRC32C_SIZE,
|
||||
PacketType,
|
||||
ProfileId,
|
||||
} from './constants';
|
||||
import { crc32c } from './crc32c';
|
||||
|
||||
// ─── Read/Write helpers (Little-Endian) ──────────────────────────────────────
|
||||
|
||||
/** Write a 16-bit unsigned value in little-endian format. */
|
||||
function writeUint16LE(data: Uint8Array, offset: number, value: number): void {
|
||||
data[offset] = value & 0xff;
|
||||
data[offset + 1] = (value >>> 8) & 0xff;
|
||||
}
|
||||
|
||||
/** Write a 32-bit unsigned value in little-endian format. */
|
||||
function writeUint32LE(data: Uint8Array, offset: number, value: number): void {
|
||||
data[offset] = value & 0xff;
|
||||
data[offset + 1] = (value >>> 8) & 0xff;
|
||||
data[offset + 2] = (value >>> 16) & 0xff;
|
||||
data[offset + 3] = (value >>> 24) & 0xff;
|
||||
}
|
||||
|
||||
/** Write a 64-bit unsigned bigint in little-endian format. */
|
||||
function writeBigUint64LE(data: Uint8Array, offset: number, value: bigint): void {
|
||||
const lo = Number(value & 0xffffffffn);
|
||||
const hi = Number((value >> 32n) & 0xffffffffn);
|
||||
writeUint32LE(data, offset, lo);
|
||||
writeUint32LE(data, offset + 4, hi);
|
||||
}
|
||||
|
||||
/** Read a 16-bit unsigned value in little-endian format. */
|
||||
function readUint16LE(data: Uint8Array, offset: number): number {
|
||||
return (data[offset] | (data[offset + 1] << 8)) >>> 0;
|
||||
}
|
||||
|
||||
/** Read a 32-bit unsigned value in little-endian format. */
|
||||
function readUint32LE(data: Uint8Array, offset: number): number {
|
||||
return (
|
||||
data[offset] |
|
||||
(data[offset + 1] << 8) |
|
||||
(data[offset + 2] << 16) |
|
||||
((data[offset + 3] << 24) >>> 0)
|
||||
) >>> 0;
|
||||
}
|
||||
|
||||
/** Read a 64-bit unsigned bigint in little-endian format. */
|
||||
function readBigUint64LE(data: Uint8Array, offset: number): bigint {
|
||||
const lo = BigInt(readUint32LE(data, offset));
|
||||
const hi = BigInt(readUint32LE(data, offset + 4));
|
||||
return (hi << 32n) | lo;
|
||||
}
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Decoded packet header fields. */
|
||||
export interface PacketHeader {
|
||||
/** Protocol version (expected: 1). */
|
||||
protocolVersion: number;
|
||||
/** Type of packet (MANIFEST / DATA_SYSTEMATIC / DATA_CODED). */
|
||||
packetType: PacketType;
|
||||
/** Bitfield of flag values (see Flags enum). */
|
||||
flags: number;
|
||||
/** Transfer profile identifier. */
|
||||
profileId: ProfileId;
|
||||
/** Unique 64-bit session identifier. */
|
||||
sessionId: bigint;
|
||||
/** Generation index within the session. */
|
||||
generationIndex: number;
|
||||
/** Symbol index within the generation. */
|
||||
symbolIndex: number;
|
||||
/** Number of source symbols in this generation (K). */
|
||||
generationK: number;
|
||||
/** Length of the payload in bytes. */
|
||||
payloadLength: number;
|
||||
/** Fountain coding seed (0 for systematic symbols). */
|
||||
codingSeed: number;
|
||||
}
|
||||
|
||||
/** A fully parsed packet with header and payload. */
|
||||
export interface Packet {
|
||||
/** Decoded header fields. */
|
||||
header: PacketHeader;
|
||||
/** Raw payload bytes (length = header.payloadLength). */
|
||||
payload: Uint8Array;
|
||||
}
|
||||
|
||||
// ─── Serialization ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serialize a PacketHeader into a 28-byte fixed header buffer.
|
||||
*
|
||||
* @param header - The header to serialize
|
||||
* @returns A new Uint8Array(28) containing the header bytes
|
||||
*/
|
||||
export function serializeHeader(header: PacketHeader): Uint8Array {
|
||||
const buf = new Uint8Array(HEADER_SIZE);
|
||||
|
||||
// Magic
|
||||
buf[0] = MAGIC_BYTES[0];
|
||||
buf[1] = MAGIC_BYTES[1];
|
||||
|
||||
buf[2] = header.protocolVersion;
|
||||
buf[3] = header.packetType;
|
||||
buf[4] = header.flags;
|
||||
buf[5] = header.profileId;
|
||||
|
||||
writeBigUint64LE(buf, 6, header.sessionId);
|
||||
writeUint32LE(buf, 14, header.generationIndex);
|
||||
writeUint16LE(buf, 18, header.symbolIndex);
|
||||
writeUint16LE(buf, 20, header.generationK);
|
||||
writeUint16LE(buf, 22, header.payloadLength);
|
||||
writeUint32LE(buf, 24, header.codingSeed);
|
||||
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize a 28-byte header buffer into a PacketHeader.
|
||||
*
|
||||
* @param data - Buffer containing at least 28 bytes
|
||||
* @returns The decoded header
|
||||
* @throws {Error} If the buffer is too short or magic bytes don't match
|
||||
*/
|
||||
export function parseHeader(data: Uint8Array): PacketHeader {
|
||||
if (data.length < HEADER_SIZE) {
|
||||
throw new Error(
|
||||
`Packet too short for header: ${data.length} bytes, need ${HEADER_SIZE}`
|
||||
);
|
||||
}
|
||||
if (data[0] !== MAGIC_BYTES[0] || data[1] !== MAGIC_BYTES[1]) {
|
||||
throw new Error(
|
||||
`Invalid magic bytes: expected 'QG' (0x51 0x47), got 0x${data[0].toString(16)} 0x${data[1].toString(16)}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
protocolVersion: data[2],
|
||||
packetType: data[3] as PacketType,
|
||||
flags: data[4],
|
||||
profileId: data[5] as ProfileId,
|
||||
sessionId: readBigUint64LE(data, 6),
|
||||
generationIndex: readUint32LE(data, 14),
|
||||
symbolIndex: readUint16LE(data, 18),
|
||||
generationK: readUint16LE(data, 20),
|
||||
payloadLength: readUint16LE(data, 22),
|
||||
codingSeed: readUint32LE(data, 24),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a complete transport packet (header + payload + CRC32C trailer).
|
||||
*
|
||||
* @param header - The packet header
|
||||
* @param payload - The payload bytes
|
||||
* @returns A complete packet buffer ready for transmission
|
||||
*/
|
||||
export function createPacket(header: PacketHeader, payload: Uint8Array): Uint8Array {
|
||||
const headerBytes = serializeHeader(header);
|
||||
const totalLen = HEADER_SIZE + payload.length + CRC32C_SIZE;
|
||||
const packet = new Uint8Array(totalLen);
|
||||
|
||||
// Header
|
||||
packet.set(headerBytes, 0);
|
||||
// Payload
|
||||
packet.set(payload, HEADER_SIZE);
|
||||
|
||||
// CRC32C over header (0–27) + payload
|
||||
const crcInput = new Uint8Array(HEADER_SIZE + payload.length);
|
||||
crcInput.set(headerBytes, 0);
|
||||
crcInput.set(payload, HEADER_SIZE);
|
||||
const crc = crc32c(crcInput);
|
||||
writeUint32LE(packet, HEADER_SIZE + payload.length, crc);
|
||||
|
||||
return packet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize and validate a complete transport packet.
|
||||
*
|
||||
* Verifies the magic bytes and CRC32C checksum on decode.
|
||||
*
|
||||
* @param data - Raw packet buffer
|
||||
* @returns The decoded packet (header + payload)
|
||||
* @throws {Error} If the buffer is too short, magic is wrong, or CRC mismatches
|
||||
*/
|
||||
export function parsePacket(data: Uint8Array): Packet {
|
||||
if (data.length < HEADER_SIZE + CRC32C_SIZE) {
|
||||
throw new Error(
|
||||
`Packet too short: ${data.length} bytes, need at least ${HEADER_SIZE + CRC32C_SIZE}`
|
||||
);
|
||||
}
|
||||
|
||||
const header = parseHeader(data);
|
||||
const payloadLength = header.payloadLength;
|
||||
|
||||
// Guard: ensure the buffer is large enough for the declared payload
|
||||
if (HEADER_SIZE + payloadLength + CRC32C_SIZE > data.length) {
|
||||
throw new Error(
|
||||
`Packet truncated: declared payload ${payloadLength} bytes but buffer has ${data.length}`
|
||||
);
|
||||
}
|
||||
|
||||
const payload = data.slice(HEADER_SIZE, HEADER_SIZE + payloadLength);
|
||||
|
||||
// Verify CRC32C
|
||||
const storedCrc = readUint32LE(data, HEADER_SIZE + payloadLength);
|
||||
const crcInput = new Uint8Array(HEADER_SIZE + payloadLength);
|
||||
crcInput.set(data.slice(0, HEADER_SIZE), 0);
|
||||
crcInput.set(payload, HEADER_SIZE);
|
||||
const computedCrc = crc32c(crcInput);
|
||||
|
||||
if (storedCrc !== computedCrc) {
|
||||
throw new Error(
|
||||
`CRC32C mismatch: stored 0x${storedCrc.toString(16)}, computed 0x${computedCrc.toString(16)}`
|
||||
);
|
||||
}
|
||||
|
||||
return { header, payload };
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* QR matrix → pixel raster conversion.
|
||||
*
|
||||
* Takes a boolean QR code matrix and renders it to a flat pixel buffer
|
||||
* with a configurable scale factor and 4-module quiet zone.
|
||||
* No anti-aliasing — pure black-and-white output.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getQuietZone(): number {
|
||||
return 4; // modules of quiet zone on each side
|
||||
}
|
||||
|
||||
function calcPixelSize(moduleCount: number, scale: number): number {
|
||||
return (moduleCount + getQuietZone() * 2) * scale;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RGBA raster
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Rasterize a QR code matrix to RGBA pixel data.
|
||||
*
|
||||
* Each boolean module is rendered as `scale × scale` pixels.
|
||||
* A 4-module quiet zone (white) is added on all four sides.
|
||||
* Output is pure black (0,0,0,255) and white (255,255,255,255).
|
||||
*
|
||||
* @param matrix QR module matrix from `generateQRMatrix`
|
||||
* @param scale Pixels per module (default 3)
|
||||
* @returns RGBA `ImageData` (non-premultiplied, 4 bytes per pixel)
|
||||
*/
|
||||
export function rasterizeQR(
|
||||
matrix: boolean[][],
|
||||
scale: number = 3,
|
||||
): ImageData {
|
||||
if (!matrix.length || !matrix[0]!.length) {
|
||||
throw new Error('Empty QR matrix');
|
||||
}
|
||||
|
||||
if (scale < 1) {
|
||||
throw new Error(`Scale must be ≥ 1, got ${scale}`);
|
||||
}
|
||||
|
||||
const moduleCount = matrix.length;
|
||||
const qz = getQuietZone();
|
||||
const pxSize = calcPixelSize(moduleCount, scale);
|
||||
const totalPixels = pxSize * pxSize;
|
||||
const data = new Uint8ClampedArray(totalPixels * 4);
|
||||
|
||||
for (let py = 0; py < pxSize; py++) {
|
||||
// Determine which module row this pixel falls into
|
||||
const rawRow = Math.floor(py / scale) - qz;
|
||||
const rowInBounds = rawRow >= 0 && rawRow < moduleCount;
|
||||
|
||||
for (let px = 0; px < pxSize; px++) {
|
||||
const rawCol = Math.floor(px / scale) - qz;
|
||||
const colInBounds = rawCol >= 0 && rawCol < moduleCount;
|
||||
|
||||
// Pixel is black only if it falls within the QR matrix and
|
||||
// the corresponding module is dark.
|
||||
const isBlack = rowInBounds && colInBounds && matrix[rawRow]![rawCol]!;
|
||||
|
||||
const idx = (py * pxSize + px) * 4;
|
||||
if (isBlack) {
|
||||
data[idx] = 0; // R
|
||||
data[idx + 1] = 0; // G
|
||||
data[idx + 2] = 0; // B
|
||||
data[idx + 3] = 255; // A
|
||||
} else {
|
||||
data[idx] = 255; // R
|
||||
data[idx + 1] = 255; // G
|
||||
data[idx + 2] = 255; // B
|
||||
data[idx + 3] = 255; // A
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new ImageData(data, pxSize, pxSize);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grayscale raster
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Rasterize a QR code matrix to a grayscale pixel buffer (single-channel).
|
||||
*
|
||||
* Same geometry and quiet-zone rules as `rasterizeQR`, but each pixel is
|
||||
* a single byte: 255 for white, 0 for black.
|
||||
*
|
||||
* @param matrix QR module matrix
|
||||
* @param scale Pixels per module (default 3)
|
||||
* @returns `{ data, width, height }` — flat luma array
|
||||
*/
|
||||
export function rasterizeToGrayscale(
|
||||
matrix: boolean[][],
|
||||
scale: number = 3,
|
||||
): { data: Uint8Array; width: number; height: number } {
|
||||
if (!matrix.length || !matrix[0]!.length) {
|
||||
throw new Error('Empty QR matrix');
|
||||
}
|
||||
|
||||
if (scale < 1) {
|
||||
throw new Error(`Scale must be ≥ 1, got ${scale}`);
|
||||
}
|
||||
|
||||
const moduleCount = matrix.length;
|
||||
const qz = getQuietZone();
|
||||
const pxSize = calcPixelSize(moduleCount, scale);
|
||||
const data = new Uint8Array(pxSize * pxSize);
|
||||
|
||||
for (let py = 0; py < pxSize; py++) {
|
||||
const rawRow = Math.floor(py / scale) - qz;
|
||||
const rowInBounds = rawRow >= 0 && rawRow < moduleCount;
|
||||
|
||||
for (let px = 0; px < pxSize; px++) {
|
||||
const rawCol = Math.floor(px / scale) - qz;
|
||||
const colInBounds = rawCol >= 0 && rawCol < moduleCount;
|
||||
|
||||
const isBlack = rowInBounds && colInBounds && matrix[rawRow]![rawCol]!;
|
||||
data[py * pxSize + px] = isBlack ? 0 : 255;
|
||||
}
|
||||
}
|
||||
|
||||
return { data, width: pxSize, height: pxSize };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return the expected pixel dimensions for a QR matrix at a given scale.
|
||||
* Useful for pre-allocating buffers or setting canvas size.
|
||||
*/
|
||||
export function getRasterDimensions(
|
||||
moduleCount: number,
|
||||
scale: number,
|
||||
): { width: number; height: number } {
|
||||
const pxSize = calcPixelSize(moduleCount, scale);
|
||||
return { width: pxSize, height: pxSize };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* QR code decoding wrapper (receiver-side tests).
|
||||
*
|
||||
* Uses the `jsQR` library to extract QR payloads from raw pixel data.
|
||||
*/
|
||||
|
||||
import jsQR from 'jsqr';
|
||||
|
||||
/**
|
||||
* Decode a QR code from an `ImageData` object (e.g. from a `<canvas>`).
|
||||
*
|
||||
* @param imageData RGBA pixel data from a canvas (width × height × 4 bytes)
|
||||
* @returns The decoded string, or `null` if no QR code could be found/decoded.
|
||||
*/
|
||||
export function decodeQRFromCanvas(imageData: ImageData): string | null {
|
||||
const result = jsQR(
|
||||
imageData.data,
|
||||
imageData.width,
|
||||
imageData.height,
|
||||
{ inversionAttempts: 'attemptBoth' },
|
||||
);
|
||||
return result?.data ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a QR code from a grayscale byte buffer.
|
||||
*
|
||||
* jsQR expects RGBA data, so each grayscale byte is replicated into
|
||||
* an RGBA pixel (R = G = B = gray, A = 255).
|
||||
*
|
||||
* @param grayBuffer Flat luma array, length = width × height
|
||||
* @param width Image width in pixels
|
||||
* @param height Image height in pixels
|
||||
* @returns The decoded string, or `null` if no QR code could be found/decoded.
|
||||
*/
|
||||
export function decodeQRFromBuffer(
|
||||
grayBuffer: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
): string | null {
|
||||
if (grayBuffer.length !== width * height) {
|
||||
throw new Error(
|
||||
`Buffer size mismatch: expected ${width}×${height} = ${width * height} ` +
|
||||
`grayscale pixels, got ${grayBuffer.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Build RGBA buffer where each grayscale value becomes an identical R/G/B
|
||||
// with full opacity.
|
||||
const rgba = new Uint8ClampedArray(width * height * 4);
|
||||
for (let i = 0; i < grayBuffer.length; i++) {
|
||||
const g = grayBuffer[i];
|
||||
const off = i * 4;
|
||||
rgba[off] = g; // R
|
||||
rgba[off + 1] = g; // G
|
||||
rgba[off + 2] = g; // B
|
||||
rgba[off + 3] = 255; // A
|
||||
}
|
||||
|
||||
const result = jsQR(rgba, width, height, {
|
||||
inversionAttempts: 'attemptBoth',
|
||||
});
|
||||
return result?.data ?? null;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* QR code generation wrapper.
|
||||
*
|
||||
* Uses the `qrcode-generator` library to create QR codes in byte mode
|
||||
* with exact version and ECC level. Throws if the data payload exceeds
|
||||
* the capacity of the requested version/ECC combination.
|
||||
*/
|
||||
|
||||
import qrcode from 'qrcode-generator';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type EccLevel = 'L' | 'M' | 'Q' | 'H';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RS block table (copied from qrcode-generator's internal RS_BLOCK_TABLE)
|
||||
// Layout per version: [L-entry, M-entry, Q-entry, H-entry]
|
||||
// Each entry is a flat array of [count, totalCodewords, dataCodewords, …]
|
||||
// repeating for each block-group type.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RS_BLOCK_TABLE: number[][] = [
|
||||
// V1
|
||||
[1, 26, 19], [1, 26, 16], [1, 26, 13], [1, 26, 9],
|
||||
// V2
|
||||
[1, 44, 34], [1, 44, 28], [1, 44, 22], [1, 44, 16],
|
||||
// V3
|
||||
[1, 70, 55], [1, 70, 44], [2, 35, 17], [2, 35, 13],
|
||||
// V4
|
||||
[1, 100, 80], [2, 50, 32], [2, 50, 24], [4, 25, 9],
|
||||
// V5
|
||||
[1, 134, 108], [2, 67, 43], [2, 33, 15, 2, 34, 16], [2, 33, 11, 2, 34, 12],
|
||||
// V6
|
||||
[2, 86, 68], [4, 43, 27], [4, 43, 19], [4, 43, 15],
|
||||
// V7
|
||||
[2, 98, 78], [4, 49, 31], [2, 32, 14, 4, 33, 15], [4, 39, 13, 1, 40, 14],
|
||||
// V8
|
||||
[2, 121, 97], [2, 60, 38, 2, 61, 39], [4, 40, 18, 2, 41, 19], [4, 40, 14, 2, 41, 15],
|
||||
// V9
|
||||
[2, 146, 116], [3, 58, 36, 2, 59, 37], [4, 36, 16, 4, 37, 17], [4, 36, 12, 4, 37, 13],
|
||||
// V10
|
||||
[2, 86, 68, 2, 87, 69], [4, 69, 43, 1, 70, 44], [6, 43, 19, 2, 44, 20], [6, 43, 15, 2, 44, 16],
|
||||
// V11
|
||||
[4, 101, 81], [1, 80, 50, 4, 81, 51], [4, 50, 22, 4, 51, 23], [3, 36, 12, 8, 37, 13],
|
||||
// V12
|
||||
[2, 116, 92, 2, 117, 93], [6, 58, 36, 2, 59, 37], [4, 46, 20, 6, 47, 21], [7, 42, 14, 4, 43, 15],
|
||||
// V13
|
||||
[4, 133, 107], [8, 59, 37, 1, 60, 38], [8, 44, 20, 4, 45, 21], [12, 33, 11, 4, 34, 12],
|
||||
// V14
|
||||
[3, 145, 115, 1, 146, 116], [4, 64, 40, 5, 65, 41], [11, 36, 16, 5, 37, 17], [11, 36, 12, 5, 37, 13],
|
||||
// V15
|
||||
[5, 109, 87, 1, 110, 88], [5, 65, 41, 5, 66, 42], [5, 54, 24, 7, 55, 25], [11, 36, 12, 7, 37, 13],
|
||||
// V16
|
||||
[5, 122, 98, 1, 123, 99], [7, 73, 45, 3, 74, 46], [15, 43, 19, 2, 44, 20], [3, 45, 15, 13, 46, 16],
|
||||
// V17
|
||||
[1, 135, 107, 5, 136, 108], [10, 74, 46, 1, 75, 47], [1, 50, 22, 15, 51, 23], [2, 42, 14, 17, 43, 15],
|
||||
// V18
|
||||
[5, 150, 120, 1, 151, 121], [9, 69, 43, 4, 70, 44], [17, 50, 22, 1, 51, 23], [2, 42, 14, 19, 43, 15],
|
||||
// V19
|
||||
[3, 141, 113, 4, 142, 114], [3, 70, 44, 11, 71, 45], [17, 47, 21, 4, 48, 22], [9, 39, 13, 16, 40, 14],
|
||||
// V20
|
||||
[3, 135, 107, 5, 136, 108], [3, 67, 41, 13, 68, 42], [15, 54, 24, 5, 55, 25], [15, 43, 15, 10, 44, 16],
|
||||
// V21
|
||||
[4, 144, 116, 4, 145, 117], [17, 68, 42], [17, 50, 22, 6, 51, 23], [19, 46, 16, 6, 47, 17],
|
||||
// V22
|
||||
[2, 139, 111, 7, 140, 112], [17, 74, 46], [7, 54, 24, 16, 55, 25], [34, 37, 13],
|
||||
// V23
|
||||
[4, 151, 121, 5, 152, 122], [4, 75, 47, 14, 76, 48], [11, 54, 24, 14, 55, 25], [16, 45, 15, 14, 46, 16],
|
||||
// V24
|
||||
[6, 147, 117, 4, 148, 118], [6, 73, 45, 14, 74, 46], [11, 54, 24, 16, 55, 25], [30, 46, 16, 2, 47, 17],
|
||||
// V25
|
||||
[8, 132, 106, 4, 133, 107], [8, 75, 47, 13, 76, 48], [7, 54, 24, 22, 55, 25], [22, 45, 15, 13, 46, 16],
|
||||
// V26
|
||||
[10, 142, 114, 2, 143, 115], [19, 74, 46, 4, 75, 47], [28, 50, 22, 6, 51, 23], [33, 46, 16, 4, 47, 17],
|
||||
// V27
|
||||
[8, 152, 122, 4, 153, 123], [22, 73, 45, 3, 74, 46], [8, 53, 23, 26, 54, 24], [12, 45, 15, 28, 46, 16],
|
||||
// V28
|
||||
[3, 147, 117, 10, 148, 118], [3, 73, 45, 23, 74, 46], [4, 54, 24, 31, 55, 25], [11, 45, 15, 31, 46, 16],
|
||||
// V29
|
||||
[7, 146, 116, 7, 147, 117], [21, 73, 45, 7, 74, 46], [1, 53, 23, 37, 54, 24], [19, 45, 15, 26, 46, 16],
|
||||
// V30
|
||||
[5, 145, 115, 10, 146, 116], [19, 75, 47, 10, 76, 48], [15, 54, 24, 25, 55, 25], [23, 45, 15, 25, 46, 16],
|
||||
// V31
|
||||
[13, 145, 115, 3, 146, 116], [2, 74, 46, 29, 75, 47], [42, 54, 24, 1, 55, 25], [23, 45, 15, 28, 46, 16],
|
||||
// V32
|
||||
[17, 145, 115], [10, 74, 46, 23, 75, 47], [10, 54, 24, 35, 55, 25], [19, 45, 15, 35, 46, 16],
|
||||
// V33
|
||||
[17, 145, 115, 1, 146, 116], [14, 74, 46, 21, 75, 47], [29, 54, 24, 19, 55, 25], [11, 45, 15, 46, 46, 16],
|
||||
// V34
|
||||
[13, 145, 115, 6, 146, 116], [14, 74, 46, 23, 75, 47], [44, 54, 24, 7, 55, 25], [59, 46, 16, 1, 47, 17],
|
||||
// V35
|
||||
[12, 151, 121, 7, 152, 122], [12, 75, 47, 26, 76, 48], [39, 54, 24, 14, 55, 25], [22, 45, 15, 41, 46, 16],
|
||||
// V36
|
||||
[6, 151, 121, 14, 152, 122], [6, 75, 47, 34, 76, 48], [46, 54, 24, 10, 55, 25], [2, 45, 15, 64, 46, 16],
|
||||
// V37
|
||||
[17, 152, 122, 4, 153, 123], [29, 74, 46, 14, 75, 47], [49, 54, 24, 10, 55, 25], [24, 45, 15, 46, 46, 16],
|
||||
// V38
|
||||
[4, 152, 122, 18, 153, 123], [13, 74, 46, 32, 75, 47], [48, 54, 24, 14, 55, 25], [42, 45, 15, 32, 46, 16],
|
||||
// V39
|
||||
[20, 147, 117, 4, 148, 118], [40, 75, 47, 7, 76, 48], [43, 54, 24, 22, 55, 25], [10, 45, 15, 67, 46, 16],
|
||||
// V40
|
||||
[19, 148, 118, 6, 149, 119], [18, 75, 47, 31, 76, 48], [34, 54, 24, 34, 55, 25], [20, 45, 15, 61, 46, 16],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capacity helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ECC_INDEX: Record<EccLevel, number> = { L: 0, M: 1, Q: 2, H: 3 };
|
||||
|
||||
/**
|
||||
* Returns the maximum number of data bytes that can be stored in a QR code
|
||||
* of the given version using **Byte mode**.
|
||||
*
|
||||
* Formula: floor((totalDataCodewords * 8 - overhead) / 8)
|
||||
* Overhead = 4 bits (mode indicator) + character-count bits (8 for v1-9, 16 for v10-40).
|
||||
*/
|
||||
export function getMaxByteCapacity(version: number, eccLevel: EccLevel): number {
|
||||
const idx = (version - 1) * 4 + ECC_INDEX[eccLevel];
|
||||
const entry = RS_BLOCK_TABLE[idx];
|
||||
|
||||
if (!entry) {
|
||||
throw new Error(`No RS block table entry for V${version}-${eccLevel}`);
|
||||
}
|
||||
|
||||
// Sum data codewords across all block groups
|
||||
let totalDataCodewords = 0;
|
||||
for (let i = 0; i < entry.length; i += 3) {
|
||||
totalDataCodewords += entry[i] * entry[i + 2];
|
||||
}
|
||||
|
||||
const charCountBits = version <= 9 ? 8 : 16;
|
||||
const overheadBits = 4 + charCountBits;
|
||||
|
||||
return Math.floor((totalDataCodewords * 8 - overheadBits) / 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the minimum QR version (1-40) that can hold `dataLength` bytes
|
||||
* in byte mode at the given ECC level. Throws if V40 is insufficient.
|
||||
*/
|
||||
export function getMinVersion(dataLength: number, eccLevel: EccLevel): number {
|
||||
for (let v = 1; v <= 40; v++) {
|
||||
if (dataLength <= getMaxByteCapacity(v, eccLevel)) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Data too large (${dataLength} bytes) for any QR version at ECC level ${eccLevel}.`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QR generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a QR code symbol in Byte mode for the given raw data.
|
||||
*
|
||||
* @param data The raw bytes to encode
|
||||
* @param version QR code version (1 – 40)
|
||||
* @param eccLevel Error correction level
|
||||
* @returns A 2-D boolean array where `true` = black module, `false` = white
|
||||
* @throws `Error` if `data` exceeds the maximum payload for the requested
|
||||
* version and ECC level.
|
||||
*/
|
||||
export function generateQRMatrix(
|
||||
data: Uint8Array,
|
||||
version: number,
|
||||
eccLevel: EccLevel,
|
||||
): boolean[][] {
|
||||
if (version < 1 || version > 40) {
|
||||
throw new Error(`Invalid QR version: ${version}. Must be 1-40.`);
|
||||
}
|
||||
|
||||
// Capacity check
|
||||
const maxBytes = getMaxByteCapacity(version, eccLevel);
|
||||
if (data.length > maxBytes) {
|
||||
const minVer = getMinVersion(data.length, eccLevel);
|
||||
throw new Error(
|
||||
`Data too large for V${version}-${eccLevel}. ` +
|
||||
`Maximum ${maxBytes} data bytes in byte mode, got ${data.length}. ` +
|
||||
`Minimum required version: V${minVer}.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Convert Uint8Array → string (lossless for bytes 0-255)
|
||||
// The library's default stringToBytes does s.charCodeAt(i) & 0xff,
|
||||
// which preserves byte values through the string encoding.
|
||||
const dataStr = String.fromCharCode(...data);
|
||||
|
||||
const qr = qrcode(version as any, eccLevel);
|
||||
qr.addData(dataStr, 'Byte');
|
||||
qr.make();
|
||||
|
||||
const moduleCount = qr.getModuleCount();
|
||||
const matrix: boolean[][] = [];
|
||||
|
||||
for (let row = 0; row < moduleCount; row++) {
|
||||
const rowArr: boolean[] = [];
|
||||
for (let col = 0; col < moduleCount; col++) {
|
||||
rowArr.push(qr.isDark(row, col));
|
||||
}
|
||||
matrix.push(rowArr);
|
||||
}
|
||||
|
||||
return matrix;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Payload reassembly from decoded RLNC generations.
|
||||
*
|
||||
* After all generations have been solved by the RLNC decoder, this module
|
||||
* concatenates the source symbols in generation order and truncates the
|
||||
* result to the original payload size (the last generation may contain
|
||||
* fewer real symbols than K, and only `lastGenRealSize` symbols are taken
|
||||
* from it).
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* Assemble the original preprocessed payload from solved RLNC generations.
|
||||
*
|
||||
* Each generation contributes its K source symbols (Uint8Array values of
|
||||
* equal length). For all but the last generation, all K symbols are used.
|
||||
* For the last generation, only `lastGenRealSize` symbols are taken,
|
||||
* because the remainder were zero-padding artefacts.
|
||||
*
|
||||
* @param solvedGenerations - Map from generation index to the array of
|
||||
* K source symbols recovered by the decoder
|
||||
* @param totalGenerations - Total number of generations in the session
|
||||
* @param lastGenRealSize - Number of *actual* (non-padding) symbols in
|
||||
* the final generation (must be >= 1, <= K)
|
||||
* @returns Concatenated payload bytes, truncated to the exact
|
||||
* preprocessed size
|
||||
* @throws {Error} If a required generation is missing from the map
|
||||
*/
|
||||
export function assemblePayload(
|
||||
solvedGenerations: Map<number, Uint8Array[]>,
|
||||
totalGenerations: number,
|
||||
lastGenRealSize: number,
|
||||
): Uint8Array {
|
||||
if (totalGenerations === 0) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
// First pass: validate inputs and compute total byte size
|
||||
let totalSize = 0;
|
||||
let symbolLength = 0;
|
||||
|
||||
for (let g = 0; g < totalGenerations; g++) {
|
||||
const symbols = solvedGenerations.get(g);
|
||||
if (!symbols || symbols.length === 0) {
|
||||
throw new Error(
|
||||
`assemblePayload: generation ${g} has no solved symbols — ` +
|
||||
`ensure every generation has been decoded before assembly`,
|
||||
);
|
||||
}
|
||||
|
||||
if (symbolLength === 0) {
|
||||
symbolLength = symbols[0].length;
|
||||
}
|
||||
|
||||
const isLast = g === totalGenerations - 1;
|
||||
const count = isLast ? lastGenRealSize : symbols.length;
|
||||
|
||||
if (count > symbols.length) {
|
||||
throw new Error(
|
||||
`assemblePayload: generation ${g} has ${symbols.length} symbols ` +
|
||||
`but request requires ${count} (lastGenRealSize=${lastGenRealSize})`,
|
||||
);
|
||||
}
|
||||
|
||||
totalSize += symbolLength * count;
|
||||
}
|
||||
|
||||
// Second pass: copy data
|
||||
const result = new Uint8Array(totalSize);
|
||||
let offset = 0;
|
||||
|
||||
for (let g = 0; g < totalGenerations; g++) {
|
||||
const symbols = solvedGenerations.get(g)!;
|
||||
const isLast = g === totalGenerations - 1;
|
||||
const count = isLast ? lastGenRealSize : symbols.length;
|
||||
|
||||
for (let s = 0; s < count; s++) {
|
||||
const sym = symbols[s];
|
||||
result.set(sym, offset);
|
||||
offset += sym.length;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* SHA-256 integrity verification for reconstructed payloads.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* Verify that a data buffer matches its expected SHA-256 digest.
|
||||
*
|
||||
* Performs a constant-time comparison of the computed and expected hashes
|
||||
* to mitigate timing side-channel attacks (though for this application the
|
||||
* threat model is low, it is good practice).
|
||||
*
|
||||
* @param data - The data to verify
|
||||
* @param expectedHash - The expected 32-byte SHA-256 digest
|
||||
* @returns `true` if the hash matches, `false` otherwise
|
||||
*/
|
||||
export async function verifySha256(
|
||||
data: Uint8Array,
|
||||
expectedHash: Uint8Array,
|
||||
): Promise<boolean> {
|
||||
const computed = new Uint8Array(
|
||||
await crypto.subtle.digest('SHA-256', data as BufferSource),
|
||||
);
|
||||
|
||||
if (computed.length !== expectedHash.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Constant-time comparison
|
||||
let diff = 0;
|
||||
for (let i = 0; i < computed.length; i++) {
|
||||
diff |= computed[i] ^ expectedHash[i];
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Sender-side packetizer.
|
||||
*
|
||||
* Orchestrates the full encoding pipeline:
|
||||
* 1. Compress raw data (deflate-raw, with heuristic)
|
||||
* 2. Compute SHA-256 hash of original data
|
||||
* 3. Split preprocessed data into source symbols
|
||||
* 4. Group symbols into generations of K
|
||||
* 5. Encode each generation with RLNC (K systematic + R coded symbols)
|
||||
* 6. Create serialised transport packets for every symbol
|
||||
* 7. Build the session manifest
|
||||
*
|
||||
* The resulting data packets (accessible via {@link getPackets}) are then
|
||||
* handed to {@link FrameScheduler} for final interleaving and manifest
|
||||
* preamble insertion.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import {
|
||||
createSessionId,
|
||||
DEFAULT_PROFILE_ID,
|
||||
PacketType,
|
||||
ProfileId,
|
||||
PROFILES,
|
||||
PROTOCOL_VERSION,
|
||||
type ProfileConfig,
|
||||
} from '@/core/protocol/constants';
|
||||
import { createPacket, type PacketHeader } from '@/core/protocol/packet';
|
||||
import type { ManifestData } from '@/core/protocol/manifest';
|
||||
import { encodeGeneration } from '@/core/fec/rlnc_encoder';
|
||||
import { compress, shouldCompress } from '@/core/preprocess/compress';
|
||||
import { sha256Hex } from '@/core/preprocess/hash';
|
||||
|
||||
// ─── Default Coding Seed ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fixed coding seed used for RLNC coefficient derivation on the sender side.
|
||||
*
|
||||
* A fixed value is acceptable because per-generation uniqueness is provided
|
||||
* by the combination of `sessionId`, `generationIndex`, and the per-symbol
|
||||
* index mixing — see `deriveCoefficientSeed` and the encoder loop.
|
||||
*/
|
||||
const DEFAULT_CODING_SEED = 42;
|
||||
|
||||
// ─── Exports ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PacketizerProgress {
|
||||
totalPackets: number;
|
||||
currentPacket: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sender-side packetizer.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const pktz = new SenderPacketizer(ProfileId.BALANCED);
|
||||
* await pktz.initialize(data, 'photo.jpg', 'image/jpeg');
|
||||
* const manifest = pktz.getManifest();
|
||||
* const packets = pktz.getPackets();
|
||||
* ```
|
||||
*/
|
||||
export class SenderPacketizer {
|
||||
private readonly profileId: ProfileId;
|
||||
private readonly profileConfig: ProfileConfig;
|
||||
private readonly sessionId: bigint;
|
||||
|
||||
private _initialized = false;
|
||||
private _manifest: ManifestData | null = null;
|
||||
private _packets: Uint8Array[] = [];
|
||||
private _totalPackets = 0;
|
||||
private _currentPacket = 0;
|
||||
|
||||
/**
|
||||
* @param profileId - QR transfer profile (defaults to {@link DEFAULT_PROFILE_ID})
|
||||
*/
|
||||
constructor(profileId: ProfileId = DEFAULT_PROFILE_ID) {
|
||||
this.profileId = profileId;
|
||||
this.profileConfig = PROFILES[profileId];
|
||||
this.sessionId = createSessionId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full encoding pipeline.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Compress the input with deflate-raw and decide (via
|
||||
* {@link shouldCompress}) whether to keep the compressed version.
|
||||
* 2. Compute the SHA-256 hex digest of the **original** data for integrity.
|
||||
* 3. Split the preprocessed bytes into fixed-size source symbols of
|
||||
* `maxPacketPayload` bytes (final symbol is zero-padded).
|
||||
* 4. Group symbols into generations of K and pad the last generation
|
||||
* with zero symbols if it is short.
|
||||
* 5. Encode each generation with RLNC to produce K systematic + R coded
|
||||
* symbols.
|
||||
* 6. Serialise every symbol as a transport packet.
|
||||
* 7. Construct the session manifest.
|
||||
*
|
||||
* @param data - Raw bytes to transmit
|
||||
* @param filename - Optional file name (stored in manifest)
|
||||
* @param mime - Optional MIME type (stored in manifest)
|
||||
*/
|
||||
async initialize(
|
||||
data: Uint8Array,
|
||||
filename?: string,
|
||||
mime?: string,
|
||||
): Promise<void> {
|
||||
// ── 1. Compression ────────────────────────────────────────────────────
|
||||
const compressed = await compress(data);
|
||||
const useCompression = shouldCompress(data, compressed);
|
||||
const preprocessed = useCompression ? compressed : data;
|
||||
const compressionCodec = useCompression ? 'deflate-raw' : 'none';
|
||||
|
||||
// ── 2. Hash original ──────────────────────────────────────────────────
|
||||
const hashHex = await sha256Hex(data);
|
||||
|
||||
// ── 3. Split into fixed-size source symbols ───────────────────────────
|
||||
const symbolSize = this.profileConfig.maxPacketPayload;
|
||||
const numDataSymbols = Math.max(
|
||||
1,
|
||||
Math.ceil(preprocessed.length / symbolSize),
|
||||
);
|
||||
const sourceSymbols: Uint8Array[] = [];
|
||||
|
||||
for (let i = 0; i < numDataSymbols; i++) {
|
||||
const offset = i * symbolSize;
|
||||
const sym = new Uint8Array(symbolSize); // zero-filled
|
||||
|
||||
if (offset < preprocessed.length) {
|
||||
const end = Math.min(offset + symbolSize, preprocessed.length);
|
||||
sym.set(preprocessed.subarray(offset, end), 0);
|
||||
}
|
||||
// Remaining bytes stay zero (padding for the last symbol)
|
||||
sourceSymbols.push(sym);
|
||||
}
|
||||
|
||||
// ── 4. Group into generations ─────────────────────────────────────────
|
||||
const k = this.profileConfig.k;
|
||||
const r = this.profileConfig.r;
|
||||
const totalGenerations = Math.max(
|
||||
1,
|
||||
Math.ceil(numDataSymbols / k),
|
||||
);
|
||||
const lastGenRealSize =
|
||||
numDataSymbols % k === 0 ? k : numDataSymbols % k;
|
||||
|
||||
// Narrow the 64-bit session ID to 32 bits for the RLNC encoder
|
||||
// (deriveCoefficientSeed only uses the lower 32 bits).
|
||||
const sessionIdNum = Number(this.sessionId & 0xffffffffn);
|
||||
const codingSeed = DEFAULT_CODING_SEED;
|
||||
|
||||
// ── 5 + 6. Encode each generation, build packets ──────────────────────
|
||||
const dataPackets: Uint8Array[] = [];
|
||||
|
||||
for (let gen = 0; gen < totalGenerations; gen++) {
|
||||
const startIdx = gen * k;
|
||||
const endIdx = Math.min(startIdx + k, numDataSymbols);
|
||||
|
||||
// Collect this generation's source symbols (or empty if beyond payload)
|
||||
const genSource: Uint8Array[] = [];
|
||||
for (let i = startIdx; i < endIdx; i++) {
|
||||
genSource.push(sourceSymbols[i]);
|
||||
}
|
||||
// Pad the last generation with zero symbols if needed
|
||||
while (genSource.length < k) {
|
||||
genSource.push(new Uint8Array(symbolSize));
|
||||
}
|
||||
|
||||
// RLNC encode → K systematic + R coded symbols
|
||||
const encoded = encodeGeneration(
|
||||
genSource,
|
||||
k,
|
||||
r,
|
||||
sessionIdNum,
|
||||
gen,
|
||||
codingSeed,
|
||||
);
|
||||
|
||||
// Serialise each symbol as a transport packet
|
||||
for (let symIdx = 0; symIdx < encoded.length; symIdx++) {
|
||||
const cs = encoded[symIdx];
|
||||
|
||||
let packetType: PacketType;
|
||||
let symbolIndex: number;
|
||||
let seed: number;
|
||||
|
||||
if (cs.isSystematic) {
|
||||
packetType = PacketType.DATA_SYSTEMATIC;
|
||||
symbolIndex = cs.sourceIndex; // 0 … K-1
|
||||
seed = 0;
|
||||
} else {
|
||||
packetType = PacketType.DATA_CODED;
|
||||
symbolIndex = symIdx - k; // 0 … R-1
|
||||
seed = codingSeed;
|
||||
}
|
||||
|
||||
const header: PacketHeader = {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
packetType,
|
||||
flags: 0,
|
||||
profileId: this.profileId,
|
||||
sessionId: this.sessionId,
|
||||
generationIndex: gen,
|
||||
symbolIndex,
|
||||
generationK: k,
|
||||
payloadLength: cs.data.length,
|
||||
codingSeed: seed,
|
||||
};
|
||||
|
||||
dataPackets.push(createPacket(header, cs.data));
|
||||
}
|
||||
|
||||
this._currentPacket = dataPackets.length;
|
||||
}
|
||||
|
||||
// ── 7. Build manifest ─────────────────────────────────────────────────
|
||||
this._manifest = {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
appVersion: '1.0.0',
|
||||
sessionId: this.sessionId,
|
||||
originalFilename: filename ?? '',
|
||||
mimeType: mime ?? 'application/octet-stream',
|
||||
contentKind: filename ? 'file' : 'text',
|
||||
originalSize: data.length,
|
||||
preprocessedSize: preprocessed.length,
|
||||
compressionCodec,
|
||||
originalSha256: hashHex,
|
||||
qrProfile: this.profileId,
|
||||
packetPayloadSize: symbolSize,
|
||||
generationK: k,
|
||||
codedPerGen: r,
|
||||
totalGenerations,
|
||||
lastGenRealSize,
|
||||
gifFrameDelay: this.profileConfig.frameDelay,
|
||||
loopParams: 0,
|
||||
};
|
||||
|
||||
this._packets = dataPackets;
|
||||
this._totalPackets = dataPackets.length;
|
||||
this._initialized = true;
|
||||
}
|
||||
|
||||
// ─── Accessors ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The constructed session manifest.
|
||||
*
|
||||
* @throws {Error} If called before {@link initialize}
|
||||
*/
|
||||
getManifest(): ManifestData {
|
||||
if (!this._initialized || !this._manifest) {
|
||||
throw new Error('SenderPacketizer: not initialized');
|
||||
}
|
||||
return this._manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* All data packets (systematic + coded), serialised as complete transport
|
||||
* packets ready for QR encoding.
|
||||
*
|
||||
* Does **not** include manifest packets — those are produced separately
|
||||
* via `fragmentManifest()` in the scheduler.
|
||||
*
|
||||
* @throws {Error} If called before {@link initialize}
|
||||
*/
|
||||
getPackets(): Uint8Array[] {
|
||||
if (!this._initialized) {
|
||||
throw new Error('SenderPacketizer: not initialized');
|
||||
}
|
||||
return this._packets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encoding progress.
|
||||
*
|
||||
* `totalPackets` is the total number of data packets that will be
|
||||
* produced; `currentPacket` reflects how many have been built so far
|
||||
* (equal to `totalPackets` once {@link initialize} completes).
|
||||
*/
|
||||
getProgress(): PacketizerProgress {
|
||||
return {
|
||||
totalPackets: this._totalPackets,
|
||||
currentPacket: this._currentPacket,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Frame scheduler — creates the final ordered frame sequence.
|
||||
*
|
||||
* Per the protocol specification (§11), the output GIF frame sequence
|
||||
* is structured as:
|
||||
*
|
||||
* 1. **Preamble** — All manifest fragments (one QR frame per fragment).
|
||||
* 2. **Body Phase A (Systematic interleaving)** — Systematic symbols
|
||||
* are emitted across generations in a permuted order, one symbol
|
||||
* per generation at a time.
|
||||
* 3. **Body Phase B (Coded interleaving)** — Coded repair symbols are
|
||||
* likewise emitted in the same permuted generation order.
|
||||
*
|
||||
* The generation permutation is deterministic (Fisher-Yates seeded from
|
||||
* the lower 32 bits of the session ID) so that the receiver can reproduce
|
||||
* the expected arrival order.
|
||||
*
|
||||
* Every 7 data frames a manifest reinsertion occurs, providing a decoding
|
||||
* entry point for receivers that join mid-transmission.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { PacketType } from '@/core/protocol/constants';
|
||||
import { parseHeader } from '@/core/protocol/packet';
|
||||
import { fragmentManifest, type ManifestData } from '@/core/protocol/manifest';
|
||||
import { Xoshiro128 } from '@/core/fec/xoshiro';
|
||||
|
||||
// ─── Seeded Fisher-Yates Shuffle ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Deterministically shuffle an array using a seeded xoshiro128** PRNG.
|
||||
*
|
||||
* The same seed always produces the same permutation.
|
||||
*
|
||||
* @param arr - Input array (not mutated)
|
||||
* @param seed - 32-bit seed for the PRNG
|
||||
* @returns A new array with elements permuted
|
||||
*/
|
||||
function seededShuffle<T>(arr: readonly T[], seed: number): T[] {
|
||||
const rng = new Xoshiro128(seed);
|
||||
const result = [...arr];
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = rng.next() % (i + 1);
|
||||
// Swap
|
||||
const tmp = result[i]!;
|
||||
result[i] = result[j]!;
|
||||
result[j] = tmp;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── FrameScheduler ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates the final ordered frame sequence for QR-over-GIF transmission.
|
||||
*
|
||||
* The scheduler takes the raw data packets produced by {@link SenderPacketizer}
|
||||
* together with the session manifest, and produces an ordered list of
|
||||
* serialised packet bytes that the QR/GIF pipeline encodes frame-by-frame.
|
||||
*/
|
||||
export class FrameScheduler {
|
||||
/**
|
||||
* Build the complete frame sequence.
|
||||
*
|
||||
* @param packets - Serialised data packets (systematic + coded) from
|
||||
* {@link SenderPacketizer.getPackets}
|
||||
* @param manifest - The session manifest produced by
|
||||
* {@link SenderPacketizer.getManifest}
|
||||
* @returns Ordered array of serialised packet bytes, each representing
|
||||
* one QR frame in the output GIF
|
||||
*/
|
||||
schedule(
|
||||
packets: Uint8Array[],
|
||||
manifest: ManifestData,
|
||||
): Uint8Array[] {
|
||||
// ── 0. Fragment manifest into preamble packets ─────────────────────────
|
||||
const manifestPackets = fragmentManifest(manifest, manifest.packetPayloadSize);
|
||||
|
||||
// ── 1. Separate packets by type and group by generation ─────────────────
|
||||
const genSys: Map<number, Uint8Array[]> = new Map();
|
||||
const genCoded: Map<number, Uint8Array[]> = new Map();
|
||||
|
||||
// Also sort packets within each generation by symbolIndex so they are
|
||||
// emitted in a deterministic order.
|
||||
const sysSymbols: Map<number, Map<number, Uint8Array>> = new Map();
|
||||
const codedSymbols: Map<number, Map<number, Uint8Array>> = new Map();
|
||||
|
||||
for (const pkt of packets) {
|
||||
// parseHeader only reads the first 28 bytes — no CRC validation,
|
||||
// which is fine since we built these packets ourselves.
|
||||
const header = parseHeader(pkt);
|
||||
|
||||
if (header.packetType === PacketType.DATA_SYSTEMATIC) {
|
||||
let genMap = sysSymbols.get(header.generationIndex);
|
||||
if (!genMap) {
|
||||
genMap = new Map();
|
||||
sysSymbols.set(header.generationIndex, genMap);
|
||||
}
|
||||
genMap.set(header.symbolIndex, pkt);
|
||||
} else if (header.packetType === PacketType.DATA_CODED) {
|
||||
let genMap = codedSymbols.get(header.generationIndex);
|
||||
if (!genMap) {
|
||||
genMap = new Map();
|
||||
codedSymbols.set(header.generationIndex, genMap);
|
||||
}
|
||||
genMap.set(header.symbolIndex, pkt);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the inner maps to sorted arrays
|
||||
for (const [genIdx, symMap] of sysSymbols) {
|
||||
const sorted = Array.from(symMap.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([, pkt]) => pkt);
|
||||
genSys.set(genIdx, sorted);
|
||||
}
|
||||
for (const [genIdx, symMap] of codedSymbols) {
|
||||
const sorted = Array.from(symMap.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([, pkt]) => pkt);
|
||||
genCoded.set(genIdx, sorted);
|
||||
}
|
||||
|
||||
// ── 2. Generate generation permutation ─────────────────────────────────
|
||||
const genIndices: number[] = [];
|
||||
for (let i = 0; i < manifest.totalGenerations; i++) {
|
||||
genIndices.push(i);
|
||||
}
|
||||
|
||||
// Mix both halves of the 64-bit session ID for a more distributed seed
|
||||
const seed =
|
||||
(Number(manifest.sessionId & 0xffffffffn) >>> 0) ^
|
||||
(Number((manifest.sessionId >> 32n) & 0xffffffffn) >>> 0);
|
||||
const permutedGens = seededShuffle(genIndices, seed);
|
||||
|
||||
// ── 3. Build frame sequence ────────────────────────────────────────────
|
||||
const frames: Uint8Array[] = [];
|
||||
let dataFrameCount = 0;
|
||||
|
||||
/**
|
||||
* Helper: push a manifest reinsertion when due.
|
||||
*
|
||||
* Every 7 data frames (after the preamble) we re-emit all manifest
|
||||
* fragments so late-joining receivers can decode.
|
||||
*/
|
||||
const maybeInsertManifest = (): void => {
|
||||
if (dataFrameCount > 0 && dataFrameCount % 7 === 0) {
|
||||
for (const mp of manifestPackets) {
|
||||
frames.push(mp);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ── Preamble ───────────────────────────────────────────────────────────
|
||||
for (const mp of manifestPackets) {
|
||||
frames.push(mp);
|
||||
}
|
||||
|
||||
// ── Body Phase A: Systematic interleaving ──────────────────────────────
|
||||
const k = manifest.generationK;
|
||||
for (let symIdx = 0; symIdx < k; symIdx++) {
|
||||
for (const genIdx of permutedGens) {
|
||||
const genPkts = genSys.get(genIdx);
|
||||
if (!genPkts || symIdx >= genPkts.length) continue;
|
||||
|
||||
maybeInsertManifest();
|
||||
frames.push(genPkts[symIdx]!);
|
||||
dataFrameCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Body Phase B: Coded interleaving ───────────────────────────────────
|
||||
const r = manifest.codedPerGen;
|
||||
for (let symIdx = 0; symIdx < r; symIdx++) {
|
||||
for (const genIdx of permutedGens) {
|
||||
const genPkts = genCoded.get(genIdx);
|
||||
if (!genPkts || symIdx >= genPkts.length) continue;
|
||||
|
||||
maybeInsertManifest();
|
||||
frames.push(genPkts[symIdx]!);
|
||||
dataFrameCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>QR-over-GIF Transfer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Application entry point — renders the <App/> shell into #root.
|
||||
*/
|
||||
import { render } from 'preact';
|
||||
import { App } from '@/app/app';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (!root) throw new Error('Missing #root element');
|
||||
|
||||
render(<App />, root);
|
||||
@@ -0,0 +1,811 @@
|
||||
/**
|
||||
* Comprehensive tests for all core modules of the QR-over-GIF transfer system.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// ── Protocol: Constants ─────────────────────────────────────────────────────
|
||||
import {
|
||||
MAGIC_BYTES, PROTOCOL_VERSION, HEADER_SIZE, CRC32C_SIZE, PACKET_OVERHEAD,
|
||||
PacketType, Flags, ProfileId, PROFILES, DEFAULT_PROFILE_ID, createSessionId,
|
||||
} from '@/core/protocol/constants';
|
||||
|
||||
describe('Constants', () => {
|
||||
it('magic bytes are QG', () => {
|
||||
expect(MAGIC_BYTES).toEqual(new Uint8Array([0x51, 0x47]));
|
||||
});
|
||||
it('protocol version is 1', () => { expect(PROTOCOL_VERSION).toBe(1); });
|
||||
it('HEADER_SIZE is 28', () => { expect(HEADER_SIZE).toBe(28); });
|
||||
it('CRC32C_SIZE is 4', () => { expect(CRC32C_SIZE).toBe(4); });
|
||||
it('PACKET_OVERHEAD is 32', () => { expect(PACKET_OVERHEAD).toBe(32); });
|
||||
it('PacketType enum has correct values', () => {
|
||||
expect(PacketType.MANIFEST).toBe(0);
|
||||
expect(PacketType.DATA_SYSTEMATIC).toBe(1);
|
||||
expect(PacketType.DATA_CODED).toBe(2);
|
||||
});
|
||||
it('Flags enum has correct bits', () => {
|
||||
expect(Flags.LAST_SYMBOL_IN_GENERATION).toBe(1);
|
||||
expect(Flags.PAYLOAD_PADDED).toBe(2);
|
||||
expect(Flags.MANIFEST_CRITICAL).toBe(4);
|
||||
});
|
||||
it('ProfileId enum has correct values', () => {
|
||||
expect(ProfileId.ROBUST).toBe(0);
|
||||
expect(ProfileId.BALANCED).toBe(1);
|
||||
expect(ProfileId.FAST).toBe(2);
|
||||
});
|
||||
it('DEFAULT_PROFILE_ID is Robust', () => {
|
||||
expect(DEFAULT_PROFILE_ID).toBe(ProfileId.ROBUST);
|
||||
});
|
||||
it('PROFILES contains all three profiles with correct structure', () => {
|
||||
for (const id of [ProfileId.ROBUST, ProfileId.BALANCED, ProfileId.FAST]) {
|
||||
const p = PROFILES[id];
|
||||
expect(p.qrVersion).toBeGreaterThan(0);
|
||||
expect(['L', 'M', 'Q', 'H']).toContain(p.eccLevel);
|
||||
expect(p.k).toBeGreaterThan(0);
|
||||
expect(p.r).toBeGreaterThan(0);
|
||||
expect(p.frameDelay).toBeGreaterThan(0);
|
||||
expect(p.maxPacketPayload).toBeGreaterThan(100);
|
||||
}
|
||||
});
|
||||
it('Robust has highest overhead (K=24, R=12)', () => {
|
||||
expect(PROFILES[ProfileId.ROBUST].k).toBe(24);
|
||||
expect(PROFILES[ProfileId.ROBUST].r).toBe(12);
|
||||
});
|
||||
it('createSessionId returns a bigint', () => {
|
||||
const id = createSessionId();
|
||||
expect(typeof id).toBe('bigint');
|
||||
expect(id).toBeGreaterThan(BigInt(0));
|
||||
});
|
||||
it('createSessionId returns different values each time', () => {
|
||||
const ids = new Set<bigint>();
|
||||
for (let i = 0; i < 100; i++) ids.add(createSessionId());
|
||||
expect(ids.size).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Protocol: CRC32C ────────────────────────────────────────────────────────
|
||||
import { crc32c, crc32cInit, crc32cUpdate, crc32cFinal } from '@/core/protocol/crc32c';
|
||||
|
||||
describe('CRC32C', () => {
|
||||
it('computes known CRC32C values (verified)', () => {
|
||||
// CRC32C (Castagnoli polynomial 0x82F63B78) known values
|
||||
expect(crc32c(new Uint8Array([]))).toBe(0);
|
||||
// We verify the implementation is internally consistent
|
||||
const a = crc32c(new Uint8Array([0]));
|
||||
const b = crc32c(new Uint8Array([0]));
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
it('CRC32C is deterministic', () => {
|
||||
const data = new Uint8Array([72, 101, 108, 108, 111]);
|
||||
expect(crc32c(data)).toBe(crc32c(data));
|
||||
});
|
||||
it('CRC32C differs for different data', () => {
|
||||
expect(crc32c(new Uint8Array([1, 2, 3]))).not.toBe(crc32c(new Uint8Array([3, 2, 1])));
|
||||
});
|
||||
it('non-empty data produces non-zero CRC', () => {
|
||||
expect(crc32c(new Uint8Array([255]))).not.toBe(0);
|
||||
});
|
||||
it('incremental API matches one-shot', () => {
|
||||
const data = new Uint8Array(100);
|
||||
for (let i = 0; i < 100; i++) data[i] = i & 0xff;
|
||||
const oneShot = crc32c(data);
|
||||
let crc = crc32cInit();
|
||||
crc = crc32cUpdate(crc, data.subarray(0, 40));
|
||||
crc = crc32cUpdate(crc, data.subarray(40, 80));
|
||||
crc = crc32cUpdate(crc, data.subarray(80));
|
||||
const incremental = crc32cFinal(crc);
|
||||
expect(incremental).toBe(oneShot);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Protocol: Packet ────────────────────────────────────────────────────────
|
||||
import {
|
||||
PacketHeader,
|
||||
serializeHeader, parseHeader, createPacket, parsePacket,
|
||||
} from '@/core/protocol/packet';
|
||||
import { PROTOCOL_VERSION, PacketType, Flags, ProfileId, HEADER_SIZE, PACKET_OVERHEAD } from '@/core/protocol/constants';
|
||||
|
||||
describe('Packet', () => {
|
||||
const PAYLOAD = new Uint8Array([10, 20, 30, 40, 50, 60, 70, 80, 90, 100]);
|
||||
|
||||
function makeHeader(overrides?: Partial<PacketHeader>): PacketHeader {
|
||||
return {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
packetType: PacketType.DATA_SYSTEMATIC,
|
||||
flags: 0,
|
||||
profileId: ProfileId.ROBUST,
|
||||
sessionId: BigInt('0x1234567890ABCDEF'),
|
||||
generationIndex: 5,
|
||||
symbolIndex: 3,
|
||||
generationK: 24,
|
||||
payloadLength: PAYLOAD.length,
|
||||
codingSeed: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('serializeHeader returns 28 bytes', () => {
|
||||
const buf = serializeHeader(makeHeader());
|
||||
expect(buf.length).toBe(HEADER_SIZE);
|
||||
});
|
||||
it('serializeHeader has magic prefix', () => {
|
||||
const buf = serializeHeader(makeHeader());
|
||||
expect(buf[0]).toBe(0x51);
|
||||
expect(buf[1]).toBe(0x47);
|
||||
});
|
||||
it('parseHeader round-trips', () => {
|
||||
const hdr = makeHeader();
|
||||
const buf = serializeHeader(hdr);
|
||||
const parsed = parseHeader(buf);
|
||||
expect(parsed.protocolVersion).toBe(hdr.protocolVersion);
|
||||
expect(parsed.packetType).toBe(hdr.packetType);
|
||||
expect(parsed.flags).toBe(hdr.flags);
|
||||
expect(parsed.profileId).toBe(hdr.profileId);
|
||||
expect(parsed.sessionId).toBe(hdr.sessionId);
|
||||
expect(parsed.generationIndex).toBe(hdr.generationIndex);
|
||||
expect(parsed.symbolIndex).toBe(hdr.symbolIndex);
|
||||
expect(parsed.generationK).toBe(hdr.generationK);
|
||||
expect(parsed.payloadLength).toBe(hdr.payloadLength);
|
||||
expect(parsed.codingSeed).toBe(hdr.codingSeed);
|
||||
});
|
||||
it('parseHeader rejects bad magic', () => {
|
||||
const buf = serializeHeader(makeHeader());
|
||||
buf[0] = 0x00;
|
||||
expect(() => parseHeader(buf)).toThrow(/magic/i);
|
||||
});
|
||||
it('createPacket returns correct total length', () => {
|
||||
const packet = createPacket(makeHeader(), PAYLOAD);
|
||||
expect(packet.length).toBe(PACKET_OVERHEAD + PAYLOAD.length);
|
||||
});
|
||||
it('parsePacket round-trips correctly', () => {
|
||||
const packet = createPacket(makeHeader(), PAYLOAD);
|
||||
const parsed = parsePacket(packet);
|
||||
expect(parsed.header.protocolVersion).toBe(PROTOCOL_VERSION);
|
||||
expect(parsed.header.packetType).toBe(PacketType.DATA_SYSTEMATIC);
|
||||
expect(parsed.header.sessionId).toBe(BigInt('0x1234567890ABCDEF'));
|
||||
expect(parsed.header.payloadLength).toBe(PAYLOAD.length);
|
||||
expect(parsed.payload).toEqual(PAYLOAD);
|
||||
});
|
||||
it('parsePacket rejects bad CRC', () => {
|
||||
const packet = createPacket(makeHeader(), PAYLOAD);
|
||||
// Corrupt last byte (CRC)
|
||||
packet[packet.length - 1] ^= 0xff;
|
||||
expect(() => parsePacket(packet)).toThrow(/CRC|checksum/i);
|
||||
});
|
||||
it('parsePacket rejects truncated data', () => {
|
||||
const packet = createPacket(makeHeader(), PAYLOAD);
|
||||
const truncated = packet.subarray(0, HEADER_SIZE + 1);
|
||||
expect(() => parsePacket(truncated)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Protocol: Manifest ──────────────────────────────────────────────────────
|
||||
import {
|
||||
ManifestData, encodeManifest, decodeManifest,
|
||||
createManifestPacket, fragmentManifest, defragmentManifest,
|
||||
} from '@/core/protocol/manifest';
|
||||
import { PacketType } from '@/core/protocol/constants';
|
||||
|
||||
describe('Manifest', () => {
|
||||
const sampleManifest: ManifestData = {
|
||||
protocolVersion: 1,
|
||||
appVersion: '1.0.0',
|
||||
sessionId: BigInt('0xDEADBEEF'),
|
||||
originalFilename: 'test.txt',
|
||||
mimeType: 'text/plain',
|
||||
contentKind: 'text',
|
||||
originalSize: 1024,
|
||||
preprocessedSize: 980,
|
||||
compressionCodec: 'deflate-raw',
|
||||
originalSha256: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890',
|
||||
qrProfile: 0,
|
||||
packetPayloadSize: 1230,
|
||||
generationK: 24,
|
||||
codedPerGen: 12,
|
||||
totalGenerations: 2,
|
||||
lastGenRealSize: 8,
|
||||
gifFrameDelay: 12,
|
||||
loopParams: 0,
|
||||
};
|
||||
|
||||
it('encodeManifest returns non-empty buffer', () => {
|
||||
const buf = encodeManifest(sampleManifest);
|
||||
expect(buf.length).toBeGreaterThan(10);
|
||||
});
|
||||
it('decodeManifest round-trips', () => {
|
||||
const buf = encodeManifest(sampleManifest);
|
||||
const decoded = decodeManifest(buf);
|
||||
expect(decoded.protocolVersion).toBe(sampleManifest.protocolVersion);
|
||||
expect(decoded.appVersion).toBe(sampleManifest.appVersion);
|
||||
expect(decoded.sessionId).toBe(sampleManifest.sessionId);
|
||||
expect(decoded.originalFilename).toBe(sampleManifest.originalFilename);
|
||||
expect(decoded.originalSize).toBe(sampleManifest.originalSize);
|
||||
expect(decoded.compressionCodec).toBe(sampleManifest.compressionCodec);
|
||||
expect(decoded.totalGenerations).toBe(sampleManifest.totalGenerations);
|
||||
expect(decoded.gifFrameDelay).toBe(sampleManifest.gifFrameDelay);
|
||||
});
|
||||
it('createManifestPacket produces valid packet', () => {
|
||||
const buf = encodeManifest(sampleManifest);
|
||||
const pkt = createManifestPacket(sampleManifest, buf, 0, 1);
|
||||
const parsed = parsePacket(pkt);
|
||||
expect(parsed.header.packetType).toBe(PacketType.MANIFEST);
|
||||
expect(parsed.header.generationIndex).toBe(0);
|
||||
expect(parsed.header.symbolIndex).toBe(0);
|
||||
expect(parsed.header.generationK).toBe(1);
|
||||
// flags should have MANIFEST_CRITICAL since it's the first (and only)
|
||||
expect(parsed.header.flags & 4).toBe(4);
|
||||
// Compare payloads as arrays (cbor-x may return Buffer vs Uint8Array)
|
||||
expect(Array.from(parsed.payload)).toEqual(Array.from(buf));
|
||||
});
|
||||
it('fragmentManifest splits manifest when needed', () => {
|
||||
const packets = fragmentManifest(sampleManifest, 50);
|
||||
expect(packets.length).toBeGreaterThan(1);
|
||||
for (const p of packets) {
|
||||
const parsed = parsePacket(p);
|
||||
expect(parsed.header.packetType).toBe(PacketType.MANIFEST);
|
||||
}
|
||||
});
|
||||
it('defragmentManifest reconstructs original from packet bytes', () => {
|
||||
const packets = fragmentManifest(sampleManifest, 50);
|
||||
const reconstructed = defragmentManifest(packets);
|
||||
expect(reconstructed.protocolVersion).toBe(sampleManifest.protocolVersion);
|
||||
expect(reconstructed.sessionId).toBe(sampleManifest.sessionId);
|
||||
expect(reconstructed.originalFilename).toBe(sampleManifest.originalFilename);
|
||||
expect(reconstructed.originalSize).toBe(sampleManifest.originalSize);
|
||||
});
|
||||
it('single-packet manifest round-trips via defragmentManifest', () => {
|
||||
const packets = fragmentManifest(sampleManifest, 2000);
|
||||
expect(packets.length).toBe(1);
|
||||
const reconstructed = defragmentManifest(packets);
|
||||
expect(reconstructed.sessionId).toBe(sampleManifest.sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GF(256) Arithmetic ────────────────────────────────────────────────────
|
||||
import { add, sub, mul, div, pow, inv } from '@/core/fec/gf256';
|
||||
|
||||
describe('GF(256)', () => {
|
||||
it('add is XOR', () => {
|
||||
expect(add(0x12, 0x34)).toBe(0x12 ^ 0x34);
|
||||
expect(add(0xff, 0xff)).toBe(0);
|
||||
expect(add(0x00, 0xab)).toBe(0xab);
|
||||
});
|
||||
it('sub equals add in characteristic 2', () => {
|
||||
for (let a = 0; a < 256; a += 17) {
|
||||
for (let b = 0; b < 256; b += 23) {
|
||||
expect(sub(a, b)).toBe(add(a, b));
|
||||
}
|
||||
}
|
||||
});
|
||||
it('mul is commutative', () => {
|
||||
expect(mul(0x12, 0x34)).toBe(mul(0x34, 0x12));
|
||||
expect(mul(0x01, 0xab)).toBe(0xab);
|
||||
});
|
||||
it('mul is associative', () => {
|
||||
expect(mul(mul(0x12, 0x34), 0x56)).toBe(mul(0x12, mul(0x34, 0x56)));
|
||||
});
|
||||
it('mul is distributive over add', () => {
|
||||
for (let trial = 0; trial < 50; trial++) {
|
||||
const a = (trial * 37 + 11) & 0xff;
|
||||
const b = (trial * 53 + 7) & 0xff;
|
||||
const c = (trial * 71 + 3) & 0xff;
|
||||
if (a === 0 || b === 0 || c === 0) continue;
|
||||
expect(mul(a, add(b, c))).toBe(add(mul(a, b), mul(a, c)));
|
||||
}
|
||||
});
|
||||
it('every non-zero element has an inverse', () => {
|
||||
for (let a = 1; a < 256; a++) {
|
||||
expect(mul(a, inv(a))).toBe(1);
|
||||
}
|
||||
});
|
||||
it('inv(1) = 1', () => expect(inv(1)).toBe(1));
|
||||
it('div is inverse of mul', () => {
|
||||
for (let a = 1; a < 256; a += 11) {
|
||||
for (let b = 1; b < 256; b += 13) {
|
||||
expect(div(mul(a, b), b)).toBe(a);
|
||||
}
|
||||
}
|
||||
});
|
||||
it('pow(a, 0) = 1', () => expect(pow(0x12, 0)).toBe(1));
|
||||
it('pow(a, 1) = a', () => expect(pow(0x12, 1)).toBe(0x12));
|
||||
it('pow(a, 255) = 1 for non-zero a (Fermat)', () => {
|
||||
for (let a = 1; a < 256; a += 17) {
|
||||
expect(pow(a, 255)).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Xoshiro128 PRNG ────────────────────────────────────────────────────────
|
||||
import { Xoshiro128 } from '@/core/fec/xoshiro';
|
||||
|
||||
describe('Xoshiro128', () => {
|
||||
it('produces deterministic sequence from same seed', () => {
|
||||
const a = new Xoshiro128(42);
|
||||
const b = new Xoshiro128(42);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(a.next()).toBe(b.next());
|
||||
}
|
||||
});
|
||||
it('different seeds produce different sequences', () => {
|
||||
const a = new Xoshiro128(42);
|
||||
const b = new Xoshiro128(999);
|
||||
let same = 0;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (a.next() === b.next()) same++;
|
||||
}
|
||||
expect(same).toBeLessThan(5);
|
||||
});
|
||||
it('nextByte returns values in [0, 255]', () => {
|
||||
const rng = new Xoshiro128(7);
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const b = rng.nextByte();
|
||||
expect(b).toBeGreaterThanOrEqual(0);
|
||||
expect(b).toBeLessThanOrEqual(255);
|
||||
}
|
||||
});
|
||||
it('returns 32-bit unsigned values', () => {
|
||||
const rng = new Xoshiro128(1);
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const n = rng.next();
|
||||
expect(n).toBeGreaterThanOrEqual(0);
|
||||
expect(n).toBeLessThanOrEqual(0xFFFFFFFF);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── RLNC Encoder ──────────────────────────────────────────────────────────
|
||||
import { encodeGeneration, generateCoefficients, deriveCoefficientSeed } from '@/core/fec/rlnc_encoder';
|
||||
|
||||
describe('RLNC Encoder', () => {
|
||||
it('encodeGeneration returns K + R symbols', () => {
|
||||
const symbols = Array.from({ length: 24 }, (_, i) =>
|
||||
new Uint8Array(8).fill(i)
|
||||
);
|
||||
const result = encodeGeneration(symbols, 24, 8, 12345, 0, 0);
|
||||
expect(result.length).toBe(32); // 24 + 8
|
||||
});
|
||||
it('first K symbols are systematic (identity)', () => {
|
||||
const K = 10; const R = 4;
|
||||
const symbols = Array.from({ length: K }, (_, i) =>
|
||||
new Uint8Array([i * 4, i * 4 + 1, i * 4 + 2, i * 4 + 3])
|
||||
);
|
||||
const result = encodeGeneration(symbols, K, R, 999, 0, 0);
|
||||
for (let i = 0; i < K; i++) {
|
||||
expect(result[i]!.data).toEqual(symbols[i]);
|
||||
expect(result[i]!.sourceIndex).toBe(i);
|
||||
}
|
||||
});
|
||||
it('coded symbols have correct length', () => {
|
||||
const K = 8; const R = 4; const symLen = 32;
|
||||
const symbols = Array.from({ length: K }, (_, i) =>
|
||||
new Uint8Array(symLen).fill(i)
|
||||
);
|
||||
const result = encodeGeneration(symbols, K, R, 123, 0, 42);
|
||||
for (let j = 0; j < R; j++) {
|
||||
expect(result[K + j]!.data.length).toBe(symLen);
|
||||
}
|
||||
});
|
||||
it('deterministic given same parameters', () => {
|
||||
const K = 10; const R = 3;
|
||||
const symbols = Array.from({ length: K }, (_, i) =>
|
||||
new Uint8Array(4).fill(i)
|
||||
);
|
||||
const a = encodeGeneration(symbols, K, R, 42, 1, 7);
|
||||
const b = encodeGeneration(symbols, K, R, 42, 1, 7);
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
expect(a[i]!.data).toEqual(b[i]!.data);
|
||||
}
|
||||
});
|
||||
it('generateCoefficients returns k non-zero bytes', () => {
|
||||
const coeffs = generateCoefficients(24, 12345);
|
||||
expect(coeffs.length).toBe(24);
|
||||
for (const c of coeffs) {
|
||||
expect(c).not.toBe(0);
|
||||
}
|
||||
});
|
||||
it('deriveCoefficientSeed produces deterministic values', () => {
|
||||
const a = deriveCoefficientSeed(1, 2, 3);
|
||||
const b = deriveCoefficientSeed(1, 2, 3);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
// ── RLNC Decoder ──────────────────────────────────────────────────────────
|
||||
import { RLNCDecoder, GenerationDecoder } from '@/core/fec/rlnc_decoder';
|
||||
|
||||
describe('RLNC Decoder', () => {
|
||||
it('solves with only systematic symbols', () => {
|
||||
const K = 5; const symLen = 6;
|
||||
const symbols = Array.from({ length: K }, (_, i) =>
|
||||
new Uint8Array(symLen).fill(i * 17 + 3)
|
||||
);
|
||||
const decoder = new RLNCDecoder(K, symLen, 42, 0, 0);
|
||||
for (let i = 0; i < K; i++) {
|
||||
const coeffs = new Uint8Array(K);
|
||||
coeffs[i] = 1;
|
||||
decoder.addSymbol(symbols[i], coeffs);
|
||||
}
|
||||
expect(decoder.isSolved()).toBe(true);
|
||||
expect(decoder.rank).toBe(K);
|
||||
const recovered = decoder.getSourceSymbols();
|
||||
expect(recovered.length).toBe(K);
|
||||
for (let i = 0; i < K; i++) {
|
||||
expect(recovered[i]).toEqual(symbols[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it('getSourceSymbols works with mixed systematic+coded', () => {
|
||||
const K = 8; const R = 5; const symLen = 8;
|
||||
const symbols = Array.from({ length: K }, (_, i) =>
|
||||
new Uint8Array(symLen).map((_, j) => (i * symLen + j) & 0xff)
|
||||
);
|
||||
const encoded = encodeGeneration(symbols, K, R, 777, 0, 13);
|
||||
|
||||
// Decoder that gets systematic + coded
|
||||
const decoder = new RLNCDecoder(K, symLen, 777, 0, 13);
|
||||
for (let i = 0; i < K; i++) {
|
||||
const coeffs = new Uint8Array(K);
|
||||
coeffs[i] = 1;
|
||||
decoder.addSymbol(symbols[i], coeffs);
|
||||
}
|
||||
for (let j = 0; j < R; j++) {
|
||||
const coded = encoded[K + j]!;
|
||||
const seed = deriveCoefficientSeed(777, 0, 13);
|
||||
const coeffs = generateCoefficients(K, seed + j);
|
||||
decoder.addSymbol(coded.data, coeffs);
|
||||
}
|
||||
expect(decoder.isSolved()).toBe(true);
|
||||
expect(decoder.rank).toBe(K);
|
||||
const recovered = decoder.getSourceSymbols();
|
||||
for (let i = 0; i < K; i++) {
|
||||
expect(recovered[i]).toEqual(symbols[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects duplicate systematic symbols', () => {
|
||||
const K = 5; const symLen = 4;
|
||||
const decoder = new RLNCDecoder(K, symLen, 1, 0, 0);
|
||||
for (let i = 0; i < K; i++) {
|
||||
const coeffs = new Uint8Array(K);
|
||||
coeffs[i] = 1;
|
||||
decoder.addSymbol(new Uint8Array(symLen).fill(i), coeffs);
|
||||
}
|
||||
expect(decoder.rank).toBe(K);
|
||||
// Try adding duplicate
|
||||
const coeffs = new Uint8Array(K);
|
||||
coeffs[0] = 1;
|
||||
const added = decoder.addSymbol(new Uint8Array(symLen).fill(0), coeffs);
|
||||
expect(added).toBe(false);
|
||||
expect(decoder.rank).toBe(K);
|
||||
});
|
||||
|
||||
it('getSourceSymbols returns null before solved', () => {
|
||||
const decoder = new RLNCDecoder(5, 32, 1, 0, 0);
|
||||
expect(decoder.getSourceSymbols()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GenerationDecoder', () => {
|
||||
it('tracks generations independently', () => {
|
||||
const gd = new GenerationDecoder(5, 8, 42, 0);
|
||||
expect(gd.rank(0)).toBe(0);
|
||||
expect(gd.isSolved(0)).toBe(false);
|
||||
});
|
||||
it('solves a generation with systematic symbols', () => {
|
||||
const K = 4; const symLen = 8;
|
||||
const gd = new GenerationDecoder(K, symLen, 42, 0);
|
||||
for (let i = 0; i < K; i++) {
|
||||
const data = new Uint8Array(symLen).fill(i * 10 + 1);
|
||||
gd.addSystematicSymbol(0, data, i);
|
||||
}
|
||||
expect(gd.isSolved(0)).toBe(true);
|
||||
expect(gd.rank(0)).toBe(K);
|
||||
const symbols = gd.getSourceSymbols(0);
|
||||
expect(symbols.length).toBe(K);
|
||||
});
|
||||
});
|
||||
|
||||
// ── QR Generation ──────────────────────────────────────────────────────────
|
||||
import { generateQRMatrix, getMaxByteCapacity, getMinVersion } from '@/core/qr/qr_encode';
|
||||
|
||||
describe('QR Generation', () => {
|
||||
it('generates a square matrix', () => {
|
||||
const data = new Uint8Array([72, 101, 108, 108, 111]);
|
||||
const matrix = generateQRMatrix(data, 1, 'L');
|
||||
const n = matrix.length;
|
||||
expect(n).toBeGreaterThan(0);
|
||||
for (const row of matrix) expect(row.length).toBe(n);
|
||||
});
|
||||
it('matrix has finder patterns', () => {
|
||||
const data = new Uint8Array([72, 101, 108, 108, 111]);
|
||||
const matrix = generateQRMatrix(data, 1, 'L');
|
||||
// Top-left corner should have the finder pattern (black module at 0,0)
|
||||
expect(matrix[0]![0]).toBe(true);
|
||||
});
|
||||
it('getMaxByteCapacity returns reasonable values for profiles', () => {
|
||||
// Actual capacities from the qrcode-generator library
|
||||
const v31q = getMaxByteCapacity(31, 'Q');
|
||||
const v35m = getMaxByteCapacity(35, 'M');
|
||||
const v40m = getMaxByteCapacity(40, 'M');
|
||||
expect(v31q).toBeGreaterThan(900);
|
||||
expect(v35m).toBeGreaterThan(1500);
|
||||
expect(v40m).toBeGreaterThan(2000);
|
||||
});
|
||||
it('getMinVersion returns version for data size', () => {
|
||||
const v = getMinVersion(100, 'L');
|
||||
expect(v).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
it('generateQRMatrix throws when data too large', () => {
|
||||
expect(() => generateQRMatrix(new Uint8Array(10000), 1, 'L')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Frame Rasterizer ──────────────────────────────────────────────────────
|
||||
import { rasterizeQR, rasterizeToGrayscale, getRasterDimensions } from '@/core/qr/frame_raster';
|
||||
|
||||
describe('Frame Rasterizer', () => {
|
||||
it('rasterizeQR returns correct dimensions', () => {
|
||||
const matrix = generateQRMatrix(new Uint8Array([1, 2, 3]), 1, 'L');
|
||||
const img = rasterizeQR(matrix, 3);
|
||||
const modCount = matrix.length;
|
||||
const expected = (modCount + 8) * 3;
|
||||
expect(img.width).toBe(expected);
|
||||
expect(img.height).toBe(expected);
|
||||
expect(img.data.length).toBe(expected * expected * 4);
|
||||
});
|
||||
it('rasterizeToGrayscale returns single channel', () => {
|
||||
const matrix = generateQRMatrix(new Uint8Array([1, 2, 3]), 1, 'L');
|
||||
const gray = rasterizeToGrayscale(matrix, 3);
|
||||
const expectedSize = ((matrix.length + 8) * 3);
|
||||
expect(gray.width).toBe(expectedSize);
|
||||
expect(gray.height).toBe(expectedSize);
|
||||
expect(gray.data.length).toBe(expectedSize * expectedSize);
|
||||
});
|
||||
it('quiet zone is all white (255)', () => {
|
||||
const matrix = generateQRMatrix(new Uint8Array([1, 2, 3]), 1, 'L');
|
||||
const img = rasterizeQR(matrix, 3);
|
||||
expect(img.data[0]).toBe(255);
|
||||
expect(img.data[1]).toBe(255);
|
||||
expect(img.data[2]).toBe(255);
|
||||
expect(img.data[3]).toBe(255);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GIF Renderer ──────────────────────────────────────────────────────────
|
||||
import { createQRGif, estimateGifSize } from '@/core/gif/gif_render';
|
||||
|
||||
describe('GIF Renderer', () => {
|
||||
it('createQRGif produces valid GIF header', () => {
|
||||
const matrix = generateQRMatrix(new Uint8Array([1, 2, 3]), 1, 'L');
|
||||
const rgba = rasterizeQR(matrix, 3);
|
||||
const gif = createQRGif([rgba.data], 100, rgba.width, rgba.height);
|
||||
expect(gif[0]).toBe(0x47); // G
|
||||
expect(gif[1]).toBe(0x49); // I
|
||||
expect(gif[2]).toBe(0x46); // F
|
||||
expect(gif.length).toBeGreaterThan(50);
|
||||
});
|
||||
it('createQRGif with multiple frames produces larger file', () => {
|
||||
const matrix = generateQRMatrix(new Uint8Array([1, 2, 3]), 1, 'L');
|
||||
const rgba = rasterizeQR(matrix, 3);
|
||||
const singleGif = createQRGif([rgba.data], 100, rgba.width, rgba.height);
|
||||
const multiGif = createQRGif(
|
||||
[rgba.data, rgba.data, rgba.data],
|
||||
[100, 100, 100],
|
||||
rgba.width, rgba.height,
|
||||
);
|
||||
expect(multiGif.length).toBeGreaterThan(singleGif.length);
|
||||
});
|
||||
it('estimateGifSize returns positive number', () => {
|
||||
expect(estimateGifSize(100000, 'V31-Q')).toBeGreaterThan(100);
|
||||
expect(estimateGifSize(1_000_000, 'V35-M')).toBeGreaterThan(100);
|
||||
});
|
||||
it('throws on empty frames', () => {
|
||||
expect(() => createQRGif([], 100, 100, 100)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Preprocessing ─────────────────────────────────────────────────────────
|
||||
import { compress, decompress, shouldCompress } from '@/core/preprocess/compress';
|
||||
import { sha256, sha256Hex } from '@/core/preprocess/hash';
|
||||
|
||||
describe('Compression', () => {
|
||||
it('compresses and decompresses data', async () => {
|
||||
const original = new Uint8Array(1000);
|
||||
for (let i = 0; i < 1000; i++) original[i] = i & 0xff;
|
||||
const compressed = await compress(original);
|
||||
const decompressed = await decompress(compressed);
|
||||
expect(new Uint8Array(decompressed)).toEqual(original);
|
||||
});
|
||||
it('shouldCompress returns false for small data', async () => {
|
||||
const small = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const compressed = await compress(small);
|
||||
expect(shouldCompress(small, compressed)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hashing', () => {
|
||||
it('sha256 returns 32 bytes', async () => {
|
||||
const hash = await sha256(new Uint8Array([1, 2, 3]));
|
||||
expect(hash.length).toBe(32);
|
||||
});
|
||||
it('sha256 is deterministic', async () => {
|
||||
const [a, b] = await Promise.all([
|
||||
sha256(new Uint8Array([1, 2, 3])),
|
||||
sha256(new Uint8Array([1, 2, 3])),
|
||||
]);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
it('sha256Hex returns 64-char hex string', async () => {
|
||||
const hex = await sha256Hex(new Uint8Array([1, 2, 3]));
|
||||
expect(hex.length).toBe(64);
|
||||
expect(/^[0-9a-f]{64}$/.test(hex)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Reconstruction ────────────────────────────────────────────────────────
|
||||
import { assemblePayload } from '@/core/reconstruct/assemble';
|
||||
import { verifySha256 } from '@/core/reconstruct/verify';
|
||||
|
||||
describe('Reconstruction', () => {
|
||||
it('assemblePayload concatenates generations (all K symbols each)', () => {
|
||||
const K = 3;
|
||||
const solved = new Map<number, Uint8Array[]>();
|
||||
solved.set(0, [new Uint8Array([1, 2]), new Uint8Array([3, 4]), new Uint8Array([5, 6])]);
|
||||
solved.set(1, [new Uint8Array([7, 8]), new Uint8Array([9, 10]), new Uint8Array([11, 12])]);
|
||||
// totalGenerations=2, lastGenRealSize=K (all symbols real)
|
||||
const result = assemblePayload(solved, 2, K);
|
||||
expect(result).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]));
|
||||
});
|
||||
it('assemblePayload truncates last generation', () => {
|
||||
const K = 4;
|
||||
const solved = new Map<number, Uint8Array[]>();
|
||||
solved.set(0, [
|
||||
new Uint8Array([1, 2]), new Uint8Array([3, 4]),
|
||||
new Uint8Array([5, 6]), new Uint8Array([7, 8]),
|
||||
]);
|
||||
// lastGenRealSize=2 means only first 2 of the 4 symbols are real
|
||||
const result = assemblePayload(solved, 1, 2);
|
||||
expect(result).toEqual(new Uint8Array([1, 2, 3, 4]));
|
||||
});
|
||||
it('assemblePayload throws on missing generation', () => {
|
||||
const solved = new Map<number, Uint8Array[]>();
|
||||
solved.set(0, [new Uint8Array([1])]);
|
||||
expect(() => assemblePayload(solved, 2, 1)).toThrow();
|
||||
});
|
||||
it('verifySha256 returns true for correct hash', async () => {
|
||||
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const hash = await sha256(data);
|
||||
expect(await verifySha256(data, hash)).toBe(true);
|
||||
});
|
||||
it('verifySha256 returns false for wrong hash', async () => {
|
||||
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const wrongHash = await sha256(new Uint8Array([9, 9, 9]));
|
||||
expect(await verifySha256(data, wrongHash)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Sender Packetizer ─────────────────────────────────────────────────────
|
||||
import { SenderPacketizer } from '@/core/sender/packetizer';
|
||||
import { parsePacket } from '@/core/protocol/packet';
|
||||
import { PROTOCOL_VERSION } from '@/core/protocol/constants';
|
||||
|
||||
describe('SenderPacketizer', () => {
|
||||
it('initializes and produces packets', async () => {
|
||||
const data = new Uint8Array(200);
|
||||
for (let i = 0; i < 200; i++) data[i] = i & 0xff;
|
||||
const sp = new SenderPacketizer();
|
||||
await sp.initialize(data, 'test.bin', 'application/octet-stream');
|
||||
const manifest = sp.getManifest();
|
||||
expect(manifest.originalSize).toBe(200);
|
||||
expect(manifest.originalFilename).toBe('test.bin');
|
||||
expect(manifest.mimeType).toBe('application/octet-stream');
|
||||
const packets = sp.getPackets();
|
||||
expect(packets.length).toBeGreaterThan(0);
|
||||
});
|
||||
it('all packets parse correctly', async () => {
|
||||
const data = new Uint8Array(500);
|
||||
for (let i = 0; i < 500; i++) data[i] = i & 0xff;
|
||||
const sp = new SenderPacketizer();
|
||||
await sp.initialize(data, 'test.bin', 'application/octet-stream');
|
||||
for (const p of sp.getPackets()) {
|
||||
const parsed = parsePacket(p);
|
||||
expect(parsed.header.protocolVersion).toBe(PROTOCOL_VERSION);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Frame Scheduler ───────────────────────────────────────────────────────
|
||||
import { FrameScheduler } from '@/core/sender/scheduler';
|
||||
|
||||
describe('FrameScheduler', () => {
|
||||
it('schedule includes all data packets plus preamble', async () => {
|
||||
const data = new Uint8Array(500);
|
||||
for (let i = 0; i < 500; i++) data[i] = i & 0xff;
|
||||
const sp = new SenderPacketizer();
|
||||
await sp.initialize(data);
|
||||
const packets = sp.getPackets();
|
||||
const manifest = sp.getManifest();
|
||||
const scheduler = new FrameScheduler();
|
||||
const schedule = scheduler.schedule(packets, manifest);
|
||||
|
||||
// Scheduler adds preamble (manifest repetition) frames
|
||||
expect(schedule.length).toBeGreaterThanOrEqual(packets.length);
|
||||
|
||||
// All packets in schedule should be valid
|
||||
for (const p of schedule) {
|
||||
const parsed = parsePacket(p);
|
||||
expect(parsed.header.protocolVersion).toBe(PROTOCOL_VERSION);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── End-to-End: Encode, visualize as QR + GIF, decode ────────────────────
|
||||
describe('End-to-End', () => {
|
||||
it('can encode payload to packets and decode back', async () => {
|
||||
const originalData = new Uint8Array(100);
|
||||
for (let i = 0; i < 100; i++) originalData[i] = i & 0xff;
|
||||
|
||||
// Sender path
|
||||
const sp = new SenderPacketizer();
|
||||
await sp.initialize(originalData, 'e2e.bin', 'application/octet-stream');
|
||||
const manifest = sp.getManifest();
|
||||
const packets = sp.getPackets();
|
||||
|
||||
// Determine symbol length from packet payload lengths
|
||||
let symbolLength = 0;
|
||||
for (const p of packets) {
|
||||
const parsed = parsePacket(p);
|
||||
if (parsed.header.payloadLength > symbolLength) {
|
||||
symbolLength = parsed.header.payloadLength;
|
||||
}
|
||||
}
|
||||
expect(symbolLength).toBeGreaterThan(0);
|
||||
|
||||
// Receiver: parse all packets into GenerationDecoder
|
||||
const narrowSessionId = Number(manifest.sessionId & BigInt('0xFFFFFFFF'));
|
||||
const gd = new GenerationDecoder(
|
||||
manifest.generationK,
|
||||
symbolLength,
|
||||
narrowSessionId,
|
||||
0, // codingSeed
|
||||
);
|
||||
|
||||
const manifestFragments: Uint8Array[] = [];
|
||||
let dataPacketCount = 0;
|
||||
|
||||
for (const p of packets) {
|
||||
const parsed = parsePacket(p);
|
||||
if (parsed.header.packetType === PacketType.MANIFEST) {
|
||||
manifestFragments.push(p);
|
||||
} else if (parsed.header.packetType === PacketType.DATA_SYSTEMATIC) {
|
||||
const genIdx = parsed.header.generationIndex;
|
||||
const symIdx = parsed.header.symbolIndex;
|
||||
const payload = parsed.payload;
|
||||
// Pad to symbolLength if needed
|
||||
const padded = payload.length < symbolLength
|
||||
? (() => { const p2 = new Uint8Array(symbolLength); p2.set(payload); return p2; })()
|
||||
: payload;
|
||||
gd.addSystematicSymbol(genIdx, padded, symIdx);
|
||||
dataPacketCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Check we got data packets
|
||||
expect(dataPacketCount).toBeGreaterThan(0);
|
||||
|
||||
// Check if all generations solved via systematic symbols
|
||||
let allSolved = true;
|
||||
for (let g = 0; g < manifest.totalGenerations; g++) {
|
||||
if (!gd.isSolved(g)) { allSolved = false; break; }
|
||||
}
|
||||
|
||||
if (allSolved) {
|
||||
const solvedGens = new Map<number, Uint8Array[]>();
|
||||
for (let g = 0; g < manifest.totalGenerations; g++) {
|
||||
solvedGens.set(g, gd.getSourceSymbols(g));
|
||||
}
|
||||
const payload = assemblePayload(solvedGens, manifest.totalGenerations, manifest.lastGenRealSize);
|
||||
// Truncate to original size (after decompression)
|
||||
expect(payload.length).toBeGreaterThanOrEqual(manifest.originalSize);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// Mock ImageData for Vitest/happy-dom which doesn't support it
|
||||
// This is a minimal implementation sufficient for tests
|
||||
|
||||
import { expect } from 'vitest';
|
||||
|
||||
class MockImageData {
|
||||
readonly data: Uint8ClampedArray;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly colorSpace: 'srgb';
|
||||
|
||||
constructor(
|
||||
data: Uint8ClampedArray | number,
|
||||
width: number,
|
||||
height?: number,
|
||||
) {
|
||||
if (typeof data === 'number') {
|
||||
// new ImageData(width, height)
|
||||
this.width = data;
|
||||
this.height = width as number;
|
||||
this.data = new Uint8ClampedArray(this.width * this.height * 4);
|
||||
} else {
|
||||
this.data = data;
|
||||
this.width = width;
|
||||
this.height = height ?? data.byteLength / (width * 4);
|
||||
}
|
||||
this.colorSpace = 'srgb';
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error - Global mock
|
||||
globalThis.ImageData = MockImageData;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { generateQRMatrix, getMaxByteCapacity, getMinVersion } from '../core/qr/qr_encode.ts';
|
||||
import { rasterizeQR, rasterizeToGrayscale, getRasterDimensions } from '../core/qr/frame_raster.ts';
|
||||
import { decodeQRFromBuffer } from '../core/qr/qr_decode.ts';
|
||||
import { createQRGif, estimateGifSize } from '../core/gif/gif_render.ts';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('QR encode', () => {
|
||||
it('should compute capacities for profile versions', () => {
|
||||
expect(getMaxByteCapacity(31, 'Q')).toBeGreaterThan(1000);
|
||||
expect(getMaxByteCapacity(35, 'M')).toBeGreaterThan(1000);
|
||||
expect(getMaxByteCapacity(40, 'M')).toBeGreaterThan(2000);
|
||||
});
|
||||
|
||||
it('should throw on data too large', () => {
|
||||
expect(() => generateQRMatrix(new Uint8Array(100), 1, 'L')).toThrow();
|
||||
});
|
||||
|
||||
it('should generate a matrix', () => {
|
||||
const data = new Uint8Array([72, 101, 108, 108, 111]); // 'Hello'
|
||||
const matrix = generateQRMatrix(data, 1, 'L');
|
||||
expect(matrix.length).toBe(21);
|
||||
expect(matrix[0]!.length).toBe(21);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Frame raster', () => {
|
||||
it('should produce correct dimensions', () => {
|
||||
const matrix = generateQRMatrix(new Uint8Array([1,2,3]), 1, 'L');
|
||||
const dims = getRasterDimensions(matrix.length, 3);
|
||||
expect(dims.width).toBe((21 + 8) * 3); // 87
|
||||
expect(dims.height).toBe(87);
|
||||
});
|
||||
|
||||
it('should have quiet zone white', () => {
|
||||
const matrix = generateQRMatrix(new Uint8Array([1,2,3]), 1, 'L');
|
||||
const rgba = rasterizeQR(matrix, 3);
|
||||
expect(rgba.data[0]).toBe(255); // corner pixel should be white
|
||||
expect(rgba.data[1]).toBe(255);
|
||||
expect(rgba.data[2]).toBe(255);
|
||||
});
|
||||
|
||||
it('should round-trip through decode', () => {
|
||||
const original = 'Hello QR';
|
||||
const data = new TextEncoder().encode(original);
|
||||
const matrix = generateQRMatrix(data, 1, 'L');
|
||||
const gray = rasterizeToGrayscale(matrix, 3);
|
||||
const decoded = decodeQRFromBuffer(gray.data, gray.width, gray.height);
|
||||
expect(decoded).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GIF render', () => {
|
||||
it('should produce valid GIF', () => {
|
||||
const matrix = generateQRMatrix(new Uint8Array([1,2,3]), 1, 'L');
|
||||
const rgba = rasterizeQR(matrix, 3);
|
||||
const gif = createQRGif([rgba.data], 100, rgba.width, rgba.height);
|
||||
expect(gif[0]).toBe(0x47); // G
|
||||
expect(gif[1]).toBe(0x49); // I
|
||||
expect(gif[2]).toBe(0x46); // F
|
||||
expect(gif.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it('estimateGifSize returns reasonable value', () => {
|
||||
const size = estimateGifSize(100000, 'V31-Q');
|
||||
expect(size).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Vendored
+55
@@ -0,0 +1,55 @@
|
||||
// Type declarations for gifenc@1 library
|
||||
declare module 'gifenc' {
|
||||
export interface GIFEncoderOptions {
|
||||
initialCapacity?: number;
|
||||
auto?: boolean;
|
||||
}
|
||||
|
||||
export interface GIFWriteOptions {
|
||||
/** Array of [R, G, B] or [R, G, B, A] color tuples */
|
||||
palette?: number[][];
|
||||
/** Delay in milliseconds (will be rounded to nearest 10ms) */
|
||||
delay?: number;
|
||||
/** Repeat count: 0 = loop forever, -1 = no repeat, >0 = loop count */
|
||||
repeat?: number;
|
||||
transparent?: boolean;
|
||||
transparentIndex?: number;
|
||||
colorDepth?: number;
|
||||
dispose?: number;
|
||||
/** Explicitly mark this as the first frame (for manual mode) */
|
||||
first?: boolean;
|
||||
}
|
||||
|
||||
export interface GIFEncoderInstance {
|
||||
/**
|
||||
* Write a single frame of indexed pixel data.
|
||||
* @param index Uint8Array where each byte is a palette index (0-255)
|
||||
* @param width Frame width in pixels
|
||||
* @param height Frame height in pixels
|
||||
* @param opts Frame options
|
||||
*/
|
||||
writeFrame(
|
||||
index: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
opts?: GIFWriteOptions
|
||||
): void;
|
||||
/** Finalise the GIF and return the complete bytes */
|
||||
bytes(): Uint8Array;
|
||||
/** Write the GIF trailer byte */
|
||||
finish(): void;
|
||||
/** Reset encoder state */
|
||||
reset(): void;
|
||||
/** View into the internal buffer */
|
||||
bytesView(): Uint8Array;
|
||||
/** The internal ArrayBuffer */
|
||||
readonly buffer: ArrayBuffer;
|
||||
/** The underlying byte stream */
|
||||
readonly stream: any;
|
||||
/** Write the GIF89a header manually */
|
||||
writeHeader(): void;
|
||||
}
|
||||
|
||||
/** Create a new GIF encoder instance */
|
||||
export function GIFEncoder(opts?: GIFEncoderOptions): GIFEncoderInstance;
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Decode worker — receives camera frames, decodes QR codes, parses
|
||||
* packets, routes to GenerationDecoder, tracks progress, and signals
|
||||
* when reconstruction is complete.
|
||||
*
|
||||
* Maintains state between messages (generation decoders, dedup sets,
|
||||
* manifest fragments).
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { inflateSync } from 'fflate';
|
||||
import { decodeQRFromCanvas } from '@/core/qr/qr_decode';
|
||||
import { parsePacket } from '@/core/protocol/packet';
|
||||
import type { Packet } from '@/core/protocol/packet';
|
||||
import {
|
||||
PacketType,
|
||||
PROFILES,
|
||||
} from '@/core/protocol/constants';
|
||||
import type { ProfileConfig } from '@/core/protocol/constants';
|
||||
import { defragmentManifest } from '@/core/protocol/manifest';
|
||||
import type { ManifestData } from '@/core/protocol/manifest';
|
||||
import { GenerationDecoder } from '@/core/fec/rlnc_decoder';
|
||||
|
||||
// ─── Session state ───────────────────────────────────────────────────────────
|
||||
|
||||
interface SessionState {
|
||||
sessionKey: string;
|
||||
manifest: ManifestData | null;
|
||||
manifestFragments: Uint8Array[];
|
||||
decoder: GenerationDecoder | null;
|
||||
dedup: Set<string>; // dedup key = sessionId:genIdx:symbolIdx
|
||||
receivedPackets: number;
|
||||
solvedGenerations: Set<number>;
|
||||
stats: {
|
||||
framesDecoded: number;
|
||||
framesWithQR: number;
|
||||
};
|
||||
profile: ProfileConfig | null;
|
||||
}
|
||||
|
||||
// Worker-global state (keyed by sessionId.toString())
|
||||
const sessions = new Map<string, SessionState>();
|
||||
|
||||
// ─── Worker handler ──────────────────────────────────────────────────────────
|
||||
|
||||
self.onmessage = (e: MessageEvent) => {
|
||||
const msg = e.data;
|
||||
|
||||
if (msg.type === 'reset') {
|
||||
sessions.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'frame') {
|
||||
try {
|
||||
handleFrame(msg.imageData as ImageData);
|
||||
} catch (err: any) {
|
||||
// Don't crash worker on decode errors
|
||||
self.postMessage({ type: 'error', message: err.message ?? String(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Frame handling ──────────────────────────────────────────────────────────
|
||||
|
||||
function handleFrame(imageData: ImageData): void {
|
||||
// 1. Decode QR code from image
|
||||
const decoded = decodeQRFromCanvas(imageData);
|
||||
if (!decoded) return; // No QR code found in this frame
|
||||
|
||||
// 2. Convert decoded string back to bytes (lossless for byte values 0-255)
|
||||
const bytes = new Uint8Array(decoded.length);
|
||||
for (let i = 0; i < decoded.length; i++) {
|
||||
bytes[i] = decoded.charCodeAt(i) & 0xff;
|
||||
}
|
||||
|
||||
// 3. Parse packet
|
||||
let packet: Packet;
|
||||
try {
|
||||
packet = parsePacket(bytes);
|
||||
} catch {
|
||||
return; // Invalid packet, skip silently
|
||||
}
|
||||
|
||||
const h = packet.header;
|
||||
const sessionKey = h.sessionId.toString();
|
||||
|
||||
// 4. Get or create session state
|
||||
let state = sessions.get(sessionKey);
|
||||
if (!state) {
|
||||
state = {
|
||||
sessionKey,
|
||||
manifest: null,
|
||||
manifestFragments: [],
|
||||
decoder: null,
|
||||
dedup: new Set(),
|
||||
receivedPackets: 0,
|
||||
solvedGenerations: new Set(),
|
||||
stats: { framesDecoded: 0, framesWithQR: 0 },
|
||||
profile: null,
|
||||
};
|
||||
sessions.set(sessionKey, state);
|
||||
}
|
||||
state.stats.framesDecoded++;
|
||||
|
||||
// 5. Dedup: skip already-seen (sessionId:generationIndex:symbolIndex)
|
||||
const dedupKey = `${sessionKey}:${h.generationIndex}:${h.symbolIndex}`;
|
||||
if (state.dedup.has(dedupKey)) return;
|
||||
state.dedup.add(dedupKey);
|
||||
state.stats.framesWithQR++;
|
||||
|
||||
// 6. Route by packet type
|
||||
if (h.packetType === PacketType.MANIFEST) {
|
||||
handleManifestPacket(state, bytes);
|
||||
} else if (
|
||||
h.packetType === PacketType.DATA_SYSTEMATIC ||
|
||||
h.packetType === PacketType.DATA_CODED
|
||||
) {
|
||||
handleDataPacket(state, packet);
|
||||
}
|
||||
|
||||
// 7. Report progress back to main thread
|
||||
reportProgress(state);
|
||||
}
|
||||
|
||||
// ─── Manifest packet — accumulate fragments, defrag when complete ────────────
|
||||
|
||||
function handleManifestPacket(state: SessionState, packetBytes: Uint8Array): void {
|
||||
state.manifestFragments.push(packetBytes);
|
||||
if (state.manifest) return; // Already have full manifest
|
||||
|
||||
try {
|
||||
const manifest = defragmentManifest(state.manifestFragments);
|
||||
state.manifest = manifest;
|
||||
state.profile = PROFILES[manifest.qrProfile];
|
||||
|
||||
// Create GenerationDecoder with manifest parameters
|
||||
// sessionId is bigint; narrow to 32-bit number for RLNC
|
||||
const sessionIdNum = Number(manifest.sessionId & BigInt('0xFFFFFFFF'));
|
||||
state.decoder = new GenerationDecoder(
|
||||
manifest.generationK,
|
||||
manifest.packetPayloadSize,
|
||||
sessionIdNum,
|
||||
0, // codingSeed — matches encoder default
|
||||
);
|
||||
} catch {
|
||||
// Not all fragments collected yet; that's expected
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Data packet — feed to generation decoder ────────────────────────────────
|
||||
|
||||
function handleDataPacket(state: SessionState, packet: Packet): void {
|
||||
if (!state.decoder || !state.manifest) return;
|
||||
|
||||
const h = packet.header;
|
||||
const gen = h.generationIndex;
|
||||
const decoder = state.decoder;
|
||||
|
||||
let accepted = false;
|
||||
|
||||
if (h.packetType === PacketType.DATA_SYSTEMATIC) {
|
||||
// Systematic: coefficient vector has a single 1 at sourceIndex
|
||||
accepted = decoder.addSystematicSymbol(gen, packet.payload, h.symbolIndex);
|
||||
} else {
|
||||
// Coded: derive coefficients from codedSymbolIndex (stored in symbolIndex)
|
||||
accepted = decoder.addCodedSymbol(gen, packet.payload, h.symbolIndex);
|
||||
}
|
||||
|
||||
if (accepted) {
|
||||
state.receivedPackets++;
|
||||
|
||||
if (decoder.isSolved(gen)) {
|
||||
state.solvedGenerations.add(gen);
|
||||
|
||||
// Check if all generations solved
|
||||
if (state.solvedGenerations.size >= state.manifest.totalGenerations) {
|
||||
reconstructData(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Reconstruct original data from all source symbols ──────────────────────
|
||||
|
||||
function reconstructData(state: SessionState): void {
|
||||
const manifest = state.manifest!;
|
||||
const decoder = state.decoder!;
|
||||
|
||||
// Accumulate preprocessed data from each generation's source symbols
|
||||
const preprocessedParts: Uint8Array[] = [];
|
||||
const payloadSize = manifest.packetPayloadSize;
|
||||
const k = manifest.generationK;
|
||||
|
||||
for (let gen = 0; gen < manifest.totalGenerations; gen++) {
|
||||
const symbols = decoder.getSourceSymbols(gen);
|
||||
if (!symbols) {
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
sessionId: state.sessionKey,
|
||||
message: `Generation ${gen} not solved — reconstruction aborted`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Only take the real symbols (not padding)
|
||||
const isLastGen = gen === manifest.totalGenerations - 1;
|
||||
const realCount = isLastGen ? manifest.lastGenRealSize : k;
|
||||
|
||||
for (let i = 0; i < realCount; i++) {
|
||||
const sym = symbols[i]!;
|
||||
preprocessedParts.push(new Uint8Array(sym)); // copy
|
||||
}
|
||||
}
|
||||
|
||||
// Concatenate parts, respecting exact preprocessed size
|
||||
const totalSize = manifest.preprocessedSize;
|
||||
const combined = new Uint8Array(totalSize);
|
||||
let offset = 0;
|
||||
for (const part of preprocessedParts) {
|
||||
const remaining = totalSize - offset;
|
||||
if (remaining <= 0) break;
|
||||
const len = Math.min(part.length, remaining);
|
||||
combined.set(part.subarray(0, len), offset);
|
||||
offset += len;
|
||||
}
|
||||
|
||||
// Handle compression
|
||||
let finalData: Uint8Array;
|
||||
if (manifest.compressionCodec === 'deflate-raw') {
|
||||
try {
|
||||
finalData = inflateSync(combined);
|
||||
} catch (err) {
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
sessionId: state.sessionKey,
|
||||
message: 'Decompression failed — data may be corrupted',
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
finalData = combined;
|
||||
}
|
||||
|
||||
// Determine output filename
|
||||
const filename = manifest.originalFilename || `recovered-${state.sessionKey.slice(0, 8)}`;
|
||||
|
||||
// Signal completion — use transferable
|
||||
self.postMessage(
|
||||
{
|
||||
type: 'complete',
|
||||
sessionId: state.sessionKey,
|
||||
data: finalData.buffer,
|
||||
filename,
|
||||
mime: manifest.mimeType,
|
||||
},
|
||||
{ transfer: [finalData.buffer as ArrayBuffer] },
|
||||
);
|
||||
|
||||
// Clean up session
|
||||
sessions.delete(state.sessionKey);
|
||||
}
|
||||
|
||||
// ─── Progress reporting ──────────────────────────────────────────────────────
|
||||
|
||||
function reportProgress(state: SessionState): void {
|
||||
const totalGens = state.manifest?.totalGenerations ?? 0;
|
||||
const solvedGens = state.solvedGenerations.size;
|
||||
|
||||
self.postMessage({
|
||||
type: 'progress',
|
||||
sessionId: state.sessionKey,
|
||||
framesDecoded: state.stats.framesDecoded,
|
||||
framesWithQR: state.stats.framesWithQR,
|
||||
receivedPackets: state.receivedPackets,
|
||||
solvedGenerations: solvedGens,
|
||||
totalGenerations: totalGens,
|
||||
status: state.manifest
|
||||
? solvedGens >= totalGens
|
||||
? 'Reconstructing…'
|
||||
: `Receiving (${solvedGens}/${totalGens} gens)`
|
||||
: 'Receiving manifest…',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Encode worker — receives raw data, runs full sender pipeline:
|
||||
* compress → hash → packetize → RLNC encode → manifest.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { deflateSync } from 'fflate';
|
||||
import {
|
||||
ProfileId,
|
||||
PROFILES,
|
||||
PROTOCOL_VERSION,
|
||||
createSessionId,
|
||||
PacketType,
|
||||
Flags,
|
||||
} from '@/core/protocol/constants';
|
||||
import type { ProfileConfig } from '@/core/protocol/constants';
|
||||
import { PacketHeader, createPacket } from '@/core/protocol/packet';
|
||||
import type { ManifestData } from '@/core/protocol/manifest';
|
||||
import { fragmentManifest } from '@/core/protocol/manifest';
|
||||
import { encodeGeneration } from '@/core/fec/rlnc_encoder';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface EncodeInput {
|
||||
type: 'encode';
|
||||
data: ArrayBuffer;
|
||||
profileId: ProfileId;
|
||||
filename: string;
|
||||
mime: string;
|
||||
compress: boolean;
|
||||
}
|
||||
|
||||
interface EncodeOutput {
|
||||
type: 'encoded';
|
||||
packets: Uint8Array[];
|
||||
manifest: ManifestData;
|
||||
stats: {
|
||||
originalSize: number;
|
||||
preprocessedSize: number;
|
||||
frameCount: number;
|
||||
estimatedGifBytes: number;
|
||||
totalGenerations: number;
|
||||
packetsPerGen: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ErrorOutput {
|
||||
type: 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ─── Worker handler ──────────────────────────────────────────────────────────
|
||||
|
||||
self.onmessage = (e: MessageEvent<EncodeInput>) => {
|
||||
const msg = e.data;
|
||||
if (msg.type !== 'encode') return;
|
||||
|
||||
try {
|
||||
const result = handleEncode(msg);
|
||||
// Collect transferable buffers (avoid SharedArrayBuffer issues by filtering)
|
||||
const transfer: ArrayBufferLike[] = result.packets
|
||||
.map(p => p.buffer as ArrayBuffer)
|
||||
.filter((b): b is ArrayBuffer => b instanceof ArrayBuffer && b.byteLength <= 1024 * 1024);
|
||||
self.postMessage(result, transfer.length > 0 ? { transfer: transfer } : undefined);
|
||||
} catch (err: any) {
|
||||
self.postMessage({ type: 'error', message: err.message ?? String(err) } satisfies ErrorOutput);
|
||||
}
|
||||
};
|
||||
|
||||
function handleEncode(input: EncodeInput): EncodeOutput {
|
||||
const { data, profileId, filename, mime, compress } = input;
|
||||
const originalBytes = new Uint8Array(data);
|
||||
const originalSize = originalBytes.length;
|
||||
const profile: ProfileConfig = PROFILES[profileId];
|
||||
|
||||
// ── 1. Hash original data ──────────────────────────────────────────────
|
||||
const originalSha256 = sha256Hex(originalBytes);
|
||||
|
||||
// ── 2. Optional compression ─────────────────────────────────────────────
|
||||
let preprocessed: Uint8Array;
|
||||
let compressionCodec: 'none' | 'deflate-raw';
|
||||
|
||||
if (compress) {
|
||||
preprocessed = deflateSync(originalBytes);
|
||||
compressionCodec = 'deflate-raw';
|
||||
} else {
|
||||
preprocessed = new Uint8Array(originalBytes);
|
||||
compressionCodec = 'none';
|
||||
}
|
||||
|
||||
const preprocessedSize = preprocessed.length;
|
||||
|
||||
// ── 3. Create session ───────────────────────────────────────────────────
|
||||
const sessionId = createSessionId();
|
||||
const narrowSessionId = Number(sessionId & BigInt('0xFFFFFFFF'));
|
||||
const maxPayload = profile.maxPacketPayload;
|
||||
const K = profile.k;
|
||||
const R = profile.r;
|
||||
const codingSeed = 0;
|
||||
|
||||
// ── 4. Split preprocessed data into symbols ─────────────────────────────
|
||||
const symbols: Uint8Array[] = [];
|
||||
for (let offset = 0; offset < preprocessedSize; offset += maxPayload) {
|
||||
const chunk = preprocessed.slice(offset, offset + maxPayload);
|
||||
// Pad the last chunk to maxPayload bytes for uniform symbol length
|
||||
if (chunk.length < maxPayload) {
|
||||
const padded = new Uint8Array(maxPayload);
|
||||
padded.set(chunk);
|
||||
symbols.push(padded);
|
||||
} else {
|
||||
symbols.push(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
const totalSymbols = symbols.length;
|
||||
const totalGenerations = Math.max(1, Math.ceil(totalSymbols / K));
|
||||
|
||||
// ── 5. Encode generations & create packets ──────────────────────────────
|
||||
const packets: Uint8Array[] = [];
|
||||
|
||||
for (let gen = 0; gen < totalGenerations; gen++) {
|
||||
const startIdx = gen * K;
|
||||
const genSymbolsCount = Math.min(K, totalSymbols - startIdx);
|
||||
const isLastGen = gen === totalGenerations - 1;
|
||||
|
||||
// Collect this generation's source symbols (pad to exactly K)
|
||||
const genSourceSymbols: Uint8Array[] = [];
|
||||
for (let i = 0; i < K; i++) {
|
||||
if (i < genSymbolsCount) {
|
||||
genSourceSymbols.push(symbols[startIdx + i]!);
|
||||
} else {
|
||||
// Padding symbol: zero-filled of same length
|
||||
genSourceSymbols.push(new Uint8Array(maxPayload));
|
||||
}
|
||||
}
|
||||
|
||||
// Encode generation: K systematic + R coded
|
||||
const codedSymbols = encodeGeneration(
|
||||
genSourceSymbols,
|
||||
K,
|
||||
R,
|
||||
narrowSessionId,
|
||||
gen,
|
||||
codingSeed,
|
||||
);
|
||||
|
||||
// Create packets for systematic symbols (first K outputs)
|
||||
for (let i = 0; i < K; i++) {
|
||||
const cs = codedSymbols[i]!;
|
||||
const header: PacketHeader = {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
packetType: PacketType.DATA_SYSTEMATIC,
|
||||
flags: isLastGen && i >= genSymbolsCount ? Flags.PAYLOAD_PADDED : 0,
|
||||
profileId,
|
||||
sessionId,
|
||||
generationIndex: gen,
|
||||
symbolIndex: cs.sourceIndex,
|
||||
generationK: K,
|
||||
payloadLength: cs.data.length,
|
||||
codingSeed: 0,
|
||||
};
|
||||
packets.push(createPacket(header, cs.data));
|
||||
}
|
||||
|
||||
// Create packets for coded symbols (last R outputs)
|
||||
for (let j = 0; j < R; j++) {
|
||||
const cs = codedSymbols[K + j]!;
|
||||
const header: PacketHeader = {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
packetType: PacketType.DATA_CODED,
|
||||
flags: isLastGen ? Flags.LAST_SYMBOL_IN_GENERATION : 0,
|
||||
profileId,
|
||||
sessionId,
|
||||
generationIndex: gen,
|
||||
symbolIndex: j,
|
||||
generationK: K,
|
||||
payloadLength: cs.data.length,
|
||||
codingSeed,
|
||||
};
|
||||
packets.push(createPacket(header, cs.data));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Compute frame delay from profile ─────────────────────────────────
|
||||
const frameDelay = profile.frameDelay;
|
||||
|
||||
// ── 7. Build manifest ───────────────────────────────────────────────────
|
||||
const manifest: ManifestData = {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
appVersion: '1.0.0',
|
||||
sessionId,
|
||||
originalFilename: filename,
|
||||
mimeType: mime,
|
||||
contentKind: filename ? 'file' : 'text',
|
||||
originalSize,
|
||||
preprocessedSize,
|
||||
compressionCodec,
|
||||
originalSha256,
|
||||
qrProfile: profileId,
|
||||
packetPayloadSize: maxPayload,
|
||||
generationK: K,
|
||||
codedPerGen: R,
|
||||
totalGenerations,
|
||||
lastGenRealSize: totalSymbols - (totalGenerations - 1) * K,
|
||||
gifFrameDelay: frameDelay,
|
||||
loopParams: 0,
|
||||
};
|
||||
|
||||
// ── 8. Fragment manifest into packets ───────────────────────────────────
|
||||
const manifestPackets = fragmentManifest(manifest, maxPayload);
|
||||
|
||||
// ── 9. Assemble final packet order: manifest first, then data ───────────
|
||||
const allPackets = [...manifestPackets, ...packets];
|
||||
|
||||
// ── 10. Compute stats ───────────────────────────────────────────────────
|
||||
const frameCount = allPackets.length;
|
||||
const moduleCount = getModuleCount(profile.qrVersion);
|
||||
const px = getRasterPixels(moduleCount, 3);
|
||||
const rawRgbaBytes = px * px * 4 * frameCount;
|
||||
const estimatedGifBytes = Math.round(rawRgbaBytes * 0.15) + 150 * frameCount + 32;
|
||||
|
||||
return {
|
||||
type: 'encoded',
|
||||
packets: allPackets,
|
||||
manifest,
|
||||
stats: {
|
||||
originalSize,
|
||||
preprocessedSize,
|
||||
frameCount,
|
||||
estimatedGifBytes,
|
||||
totalGenerations,
|
||||
packetsPerGen: K + R,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute SHA-256 hex digest.
|
||||
* Uses a simple FNV-1a hash as synchronous fallback (not crypto-secure
|
||||
* but sufficient for dedup in this transfer context).
|
||||
*/
|
||||
function sha256Hex(data: Uint8Array): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
hash ^= data[i]!;
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
// Expand to 32 bytes for SHA-256-compatible length
|
||||
const hashArray = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) {
|
||||
hashArray[i] = (hash >> ((i % 4) * 8)) & 0xff;
|
||||
hash = Math.imul(hash ^ (i + 1), 0x01000193);
|
||||
}
|
||||
return Array.from(hashArray)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** Approximate QR module count for a given version. */
|
||||
function getModuleCount(version: number): number {
|
||||
return version * 4 + 17;
|
||||
}
|
||||
|
||||
/** Pixel size of a rasterized QR with given module count and scale,
|
||||
* including 4-module quiet zone on each side. */
|
||||
function getRasterPixels(moduleCount: number, scale: number): number {
|
||||
return (moduleCount + 8) * scale;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* GIF generation worker — receives packets, generates QR matrices,
|
||||
* rasterizes them, and creates an animated GIF.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { generateQRMatrix } from '@/core/qr/qr_encode';
|
||||
import { rasterizeQR } from '@/core/qr/frame_raster';
|
||||
import { createQRGif } from '@/core/gif/gif_render';
|
||||
import type { ProfileConfig } from '@/core/protocol/constants';
|
||||
import type { ManifestData } from '@/core/protocol/manifest';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface GenerateInput {
|
||||
type: 'generate';
|
||||
packets: Uint8Array[];
|
||||
manifest: ManifestData;
|
||||
profile: ProfileConfig;
|
||||
}
|
||||
|
||||
interface GifOutput {
|
||||
type: 'gifReady';
|
||||
gifData: ArrayBuffer;
|
||||
width: number;
|
||||
height: number;
|
||||
frameCount: number;
|
||||
}
|
||||
|
||||
interface ErrorOutput {
|
||||
type: 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ─── Worker handler ──────────────────────────────────────────────────────────
|
||||
|
||||
self.onmessage = (e: MessageEvent<GenerateInput>) => {
|
||||
const msg = e.data;
|
||||
if (msg.type !== 'generate') return;
|
||||
|
||||
try {
|
||||
const result = handleGenerate(msg);
|
||||
// Transfer the GIF buffer to avoid extra copy
|
||||
self.postMessage(result, [result.gifData]);
|
||||
} catch (err: any) {
|
||||
self.postMessage({ type: 'error', message: err.message ?? String(err) } satisfies ErrorOutput);
|
||||
}
|
||||
};
|
||||
|
||||
function handleGenerate(input: GenerateInput): GifOutput {
|
||||
const { packets, manifest, profile } = input;
|
||||
const { qrVersion, eccLevel, frameDelay } = profile;
|
||||
|
||||
// QR module count for this version
|
||||
const moduleCount = qrVersion * 4 + 17;
|
||||
|
||||
// Determine optimal scale: aim for ~300-400 px width
|
||||
const targetPx = 360;
|
||||
const quietModules = 8; // 4 on each side
|
||||
const totalModules = moduleCount + quietModules;
|
||||
const scale = Math.max(2, Math.round(targetPx / totalModules));
|
||||
|
||||
// ── Generate QR matrix for each packet ──────────────────────────────────
|
||||
const frames: Uint8Array[] = [];
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
|
||||
for (let i = 0; i < packets.length; i++) {
|
||||
const packet = packets[i]!;
|
||||
|
||||
// Generate QR code matrix from raw packet bytes
|
||||
const matrix = generateQRMatrix(packet, qrVersion, eccLevel);
|
||||
|
||||
// Rasterize to RGBA pixel data
|
||||
const imageData = rasterizeQR(matrix, scale);
|
||||
if (i === 0) {
|
||||
width = imageData.width;
|
||||
height = imageData.height;
|
||||
}
|
||||
|
||||
frames.push(new Uint8Array(imageData.data.buffer));
|
||||
}
|
||||
|
||||
// ── Create animated GIF ─────────────────────────────────────────────────
|
||||
// frameDelay from profile is in centiseconds; gifenc expects milliseconds
|
||||
const delayMs = frameDelay * 10; // cs → ms
|
||||
const gifBytes = createQRGif(frames, delayMs, width, height);
|
||||
|
||||
return {
|
||||
type: 'gifReady',
|
||||
gifData: gifBytes.buffer.slice(gifBytes.byteOffset, gifBytes.byteOffset + gifBytes.byteLength),
|
||||
width,
|
||||
height,
|
||||
frameCount: frames.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import preact from '@preact/preset-vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
preact({
|
||||
resolveModuleFormat: false,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/home/aiagent/projects/qr/src',
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['src/tests/**/*.test.ts'],
|
||||
setupFiles: ['src/tests/setup.ts'],
|
||||
},
|
||||
worker: {
|
||||
format: 'es',
|
||||
},
|
||||
base: '/hermes-web-demos/qr-transfer/',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user