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:
Hermes Agent
2026-05-02 21:37:06 +00:00
commit b20e5c9c17
38 changed files with 6232 additions and 0 deletions
+34
View File
@@ -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
+15
View File
@@ -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.
+408
View File
@@ -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
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
console.log("Hello via Bun!");
+31
View File
@@ -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"
}
}
+91
View File
@@ -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>
);
}
+501
View File
@@ -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)'} &middot;{' '}
{formatBytes(receivedFile.data.byteLength)} &middot;{' '}
{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`;
}
+444
View File
@@ -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`;
}
+175
View File
@@ -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;
}
+441
View File
@@ -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;
}
}
+204
View File
@@ -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;
}
+100
View File
@@ -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;
}
+136
View File
@@ -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
}
+134
View File
@@ -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;
}
+37
View File
@@ -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('');
}
+134
View File
@@ -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 027). */
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 (140). */
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;
}
+108
View File
@@ -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;
}
+269
View File
@@ -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);
}
+247
View File
@@ -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 027 + 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 (027) + 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 };
}
+146
View File
@@ -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 };
}
+64
View File
@@ -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;
}
+210
View File
@@ -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;
}
+86
View File
@@ -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;
}
+36
View File
@@ -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;
}
+287
View File
@@ -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,
};
}
}
+188
View File
@@ -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;
}
}
+12
View File
@@ -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>
+10
View File
@@ -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);
+811
View File
@@ -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);
}
});
});
+32
View File
@@ -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;
+67
View File
@@ -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);
});
});
+55
View File
@@ -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;
}
+286
View File
@@ -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…',
});
}
+271
View File
@@ -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;
}
+97
View File
@@ -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,
};
}
+24
View File
@@ -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"]
}
+28
View File
@@ -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,
},
});