mirror of
https://github.com/infrost/RaptorQR.git
synced 2026-08-31 23:17:57 +08:00
migrate to RaptorQR monorepo
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
||||
# dependencies (bun install)
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
# output
|
||||
|
||||
+65
-356
@@ -1,370 +1,79 @@
|
||||
# QR Stream - Architecture
|
||||
# RaptorQR Architecture
|
||||
|
||||
This document describes the internal design, wire format, and algorithms used by QR Stream. For user-facing installation and usage instructions, see [README.md](README.md).
|
||||
RaptorQR is a pnpm monorepo for camera-based QR transfer. The project is split so the protocol and codec code can become a reusable low-level library, while the CLI and web app remain consumers of that library.
|
||||
|
||||
---
|
||||
## Monorepo Layout
|
||||
|
||||
## Table of Contents
|
||||
```text
|
||||
packages/raptorqr-core
|
||||
src/protocol fixed header, CRC32C, transfer profiles
|
||||
src/sender packetizers and frame scheduling
|
||||
src/fec RaptorQ facade, deprecated JS RLNC, outer RS helpers
|
||||
src/qr QR capacity, encode/decode facades, raster helpers
|
||||
src/gif GIF parse/render helpers
|
||||
src/reconstruct payload assembly
|
||||
|
||||
1. [High-Level Architecture](#high-level-architecture)
|
||||
2. [Project Structure](#project-structure)
|
||||
3. [Dependencies](#dependencies)
|
||||
4. [The Protocol](#the-protocol)
|
||||
5. [Algorithms](#algorithms)
|
||||
6. [Data Flow](#data-flow)
|
||||
7. [Design Decisions](#design-decisions)
|
||||
8. [Common Pitfalls](#common-pitfalls)
|
||||
packages/raptorqr-wasm
|
||||
src/fast_qr fast_qr wasm-bindgen artifacts and Colab script
|
||||
src/raptorq cberner/raptorq wasm-bindgen artifacts and Colab script
|
||||
|
||||
---
|
||||
packages/raptorqr-cli
|
||||
src/raptorqr.ts CLI entrypoint
|
||||
src/terminal_raster.ts terminal QR renderer
|
||||
src/static_server.ts built web app preview server
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Sender UI │──────▶│ Encode Worker │──────▶│ GIF Worker │
|
||||
│ (Preact hooks) │ │ (Web Worker) │ │ (Web Worker) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────┐
|
||||
│ .gif file │
|
||||
│ (animated) │
|
||||
└────────────┘
|
||||
│
|
||||
┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ Receiver UI │◀──────│ Decode Worker │◀──────├──────── camera / file
|
||||
│ (Preact hooks) │ │ (Web Worker) │ │
|
||||
└─────────────────┘ └─────────────────┘ └──────────────────────────┘
|
||||
apps/web
|
||||
src/app Preact routes and UI
|
||||
src/workers encode, decode, GIF, and QR render workers
|
||||
src/lib app-local worker orchestration
|
||||
src/tests browser/WebAssembly integration tests
|
||||
```
|
||||
|
||||
Everything heavy (compression, RLNC encoding, GIF encoding, QR decoding) runs in dedicated Web Workers so the UI stays responsive.
|
||||
## Package Boundaries
|
||||
|
||||
---
|
||||
`@raptorqr/core` owns protocol behavior and public transfer APIs. It exports environment-neutral modules from `@raptorqr/core`, browser wrappers from `@raptorqr/core/browser`, and Node/CLI wrappers from `@raptorqr/core/node`.
|
||||
|
||||
## Project Structure
|
||||
`@raptorqr/wasm` owns generated WASM artifacts. Core imports fast_qr through `@raptorqr/wasm/fast-qr` and RaptorQ through `@raptorqr/wasm/raptorq`.
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── app.tsx # App shell with hash-based tab routing
|
||||
│ └── routes/
|
||||
│ ├── sender.tsx # Text/file input, GIF preview, download
|
||||
│ └── receiver.tsx # Camera scanner, GIF upload, results
|
||||
├── core/
|
||||
│ ├── fec/ # Forward Error Correction (RLNC)
|
||||
│ │ ├── gf256.ts # GF(256) arithmetic (log/antilog tables)
|
||||
│ │ ├── rlnc_encoder.ts # Systematic RLNC encoder
|
||||
│ │ ├── rlnc_decoder.ts # Incremental Gaussian-elimination decoder
|
||||
│ │ └── xoshiro.ts # xoshiro128** PRNG for deterministic coeffs
|
||||
│ ├── gif/
|
||||
│ │ ├── gif_parser.ts # GIF decoding (LZW, frame extraction)
|
||||
│ │ └── gif_render.ts # GIF encoding (2-colour palette, gifenc)
|
||||
│ ├── preprocess/
|
||||
│ │ └── compress.ts # (legacy) deflate helpers
|
||||
│ ├── protocol/
|
||||
│ │ ├── constants.ts # Protocol constants: K/R, sizes, flags
|
||||
│ │ ├── crc32c.ts # CRC32-C (Castagnoli) with lookup table
|
||||
│ │ └── packet.ts # 8-byte fixed header + payload + CRC
|
||||
│ ├── qr/
|
||||
│ │ ├── qr_encode.ts # QR matrix generation (qrcode-generator)
|
||||
│ │ ├── qr_decode.ts # QR decoding wrapper (jsQR)
|
||||
│ │ └── frame_raster.ts # Matrix → RGBA raster (scale, quiet zone)
|
||||
│ ├── reconstruct/
|
||||
│ │ └── assemble.ts # Concatenate generations, trim padding
|
||||
│ └── sender/
|
||||
│ ├── packetizer.ts # Data → packets: compress, split, wrap metadata
|
||||
│ └── scheduler.ts # Interleave systematic/coded symbols into frames
|
||||
├── workers/
|
||||
│ ├── encode.worker.ts # Orchestrates packetizer + scheduler
|
||||
│ ├── decode.worker.ts # Feeds frames to RLNC decoder, reassembles
|
||||
│ └── gif.worker.ts # Rasters packets → RGBA → GIF via gifenc
|
||||
├── tests/
|
||||
│ ├── complete.test.ts # Unit tests for all core modules
|
||||
│ ├── frame_decode.test.ts # QR encode→decode roundtrip per frame
|
||||
│ ├── gif_roundtrip.test.ts# Full GIF encode→parse→decode roundtrip
|
||||
│ ├── prod_roundtrip.test.ts# Deterministic frame-loss recovery test
|
||||
│ ├── test_qr_modules.test.ts# QR capacity, raster, GIF render tests
|
||||
│ └── setup.ts # happy-dom test environment setup
|
||||
├── types/
|
||||
│ └── gifenc.d.ts # Type declarations for gifenc
|
||||
├── cli/
|
||||
│ ├── qr-stream.ts # CLI entry point (terminal QR display)
|
||||
│ │ # stdin → isText=true (text mode)
|
||||
│ │ # <file> → isText=false, embeds filename+MIME
|
||||
│ ├── terminal_raster.ts # Half-block Unicode renderer
|
||||
│ └── static_server.ts # Built-in preview server (--serve)
|
||||
├── index.html # Single-page app entry
|
||||
└── main.tsx # Renders <App/> into #root
|
||||
`@raptorqr/cli` depends on core and wasm. It bundles to `packages/raptorqr-cli/dist/raptorqr.js` and copies required WASM sidecars next to the bundle.
|
||||
|
||||
`@raptorqr/web` is private. It owns Vite, Preact UI, workers, and app-local worker pools. App-local `@/*` imports must not leak into packages.
|
||||
|
||||
## Protocol
|
||||
|
||||
The transport packet keeps the existing 8-byte header plus payload plus CRC32C trailer. The protocol does not add QR profile negotiation to the header; the receiver infers QR version from decoded symbols and packet payload size.
|
||||
|
||||
FEC codec detection uses the existing symbol index field:
|
||||
|
||||
* `symbolIndex = 0..23`: deprecated JS RLNC compatible packets
|
||||
* `symbolIndex = 31`: primary RaptorQ WASM packets
|
||||
|
||||
`wasm-raptorq` is the default FEC codec. `js-rlnc` remains explicit and test-covered, but it is deprecated and is never used as an automatic fallback if RaptorQ WASM is unavailable.
|
||||
|
||||
## QR Encoding And Decoding
|
||||
|
||||
QR generation and FEC are separate layers:
|
||||
|
||||
* FEC codec: `wasm-raptorq` or deprecated `js-rlnc`
|
||||
* QR encoder: `fast-qr-wasm` or `zxing-wasm`
|
||||
|
||||
fast_qr WASM exposes both RGBA rendering and raw matrix output. The web app uses RGBA output for live/GIF rendering; the CLI uses matrix output for terminal rendering.
|
||||
|
||||
ZXing WASM is used for decoding and remains available as a QR writer option in the browser.
|
||||
|
||||
## Build And Test
|
||||
|
||||
Root commands orchestrate package-level commands:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
pnpm test
|
||||
pnpm dev:web
|
||||
```
|
||||
|
||||
---
|
||||
The test split follows ownership:
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Runtime
|
||||
|
||||
- **preact** - UI framework (React-compatible, ~10 KB)
|
||||
- **qrcode-generator** - QR matrix generation (versions 1–40, all ECC levels)
|
||||
- **jsqr** - QR decoding from `ImageData` (grayscale + adaptive thresholding internally)
|
||||
- **gifenc** - Animated GIF encoder (2-colour palette, LZW compression)
|
||||
- **fflate** - Fast deflate/inflate (compression for large payloads)
|
||||
|
||||
### Dev / Build
|
||||
|
||||
- **vite** - Build tool, dev server, worker bundling
|
||||
- **@preact/preset-vite** - Preact JSX transform for Vite
|
||||
- **vitest** - Test runner
|
||||
- **happy-dom** - DOM environment for headless tests
|
||||
- **typescript** - Type checking
|
||||
- **esbuild** - CLI bundle (via `build:cli` script)
|
||||
|
||||
---
|
||||
|
||||
## The Protocol
|
||||
|
||||
There is **one hardcoded profile** - no negotiation, no manifest, no session IDs.
|
||||
|
||||
### Profile Constants
|
||||
|
||||
- **QR Version:** V10 (57×57 modules)
|
||||
- **ECC Level:** M (~15% correction)
|
||||
- **Source symbols per generation (K):** 16
|
||||
- **Repair symbols per generation (R):** 8
|
||||
- **Symbol payload:** 201 bytes
|
||||
- **Max packet size:** 213 bytes (fits exactly in V10-M)
|
||||
- **Frame delay:** 200 ms (5 fps)
|
||||
- **Max file size:** ~8 MB
|
||||
|
||||
### Packet Format (fixed 8-byte header)
|
||||
|
||||
All multi-byte fields are **little-endian**.
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
─────────────────────────────────────────────────────────────────────────────────
|
||||
0 1 Magic: 0x51 ('Q')
|
||||
1 4 Packed word (32 bits):
|
||||
├─ bits 0–11 : generation index (0–4095)
|
||||
├─ bits 12–23 : total generations (0–4095)
|
||||
├─ bits 24–28 : symbol index (0–31)
|
||||
├─ bit 29 : isText flag (1 = text, 0 = file)
|
||||
├─ bit 30 : isLastGeneration flag
|
||||
└─ bit 31 : compressed flag
|
||||
5 3 Data length (preprocessed size, 24-bit, 0–16,777,215)
|
||||
8 201 Payload (zero-padded to 201 B)
|
||||
209 4 CRC32-C over bytes 0–208
|
||||
```
|
||||
|
||||
Total: **213 bytes** → fits in a V10-M QR code.
|
||||
|
||||
**Symbol index convention:**
|
||||
- `0–15` = systematic symbol (`sourceIndex = symbolIndex`)
|
||||
- `16–23` = coded repair symbol (`codedSymbolIndex = symbolIndex − 16`)
|
||||
- `24–31` = reserved
|
||||
|
||||
### File Metadata Wrapping
|
||||
|
||||
For file transfers (not text), the raw file bytes are prefixed with a tiny metadata envelope before compression:
|
||||
|
||||
```
|
||||
[1 byte: filename length (0–255)]
|
||||
[N bytes: filename UTF-8]
|
||||
[1 byte: MIME type length (0–255)]
|
||||
[M bytes: MIME type UTF-8]
|
||||
[rest: actual file data]
|
||||
```
|
||||
|
||||
This lets the receiver restore the original filename and MIME type on download.
|
||||
|
||||
---
|
||||
|
||||
## Algorithms
|
||||
|
||||
### 1. RLNC over GF(256)
|
||||
|
||||
We use **Random Linear Network Coding** with a systematic encoding.
|
||||
|
||||
**Encoder (`rlnc_encoder.ts`):**
|
||||
- Given `K` source symbols, output `K` systematic + `R` coded symbols.
|
||||
- Systematic symbols are the original data (identity coefficient vector).
|
||||
- Each coded symbol is a random linear combination: `C_j = Σ coeff[i] · S_i` (multiplication and addition in GF(256)).
|
||||
- Coefficients are deterministically derived from `(generationIndex, codedSymbolIndex)` via a xoshiro128** PRNG.
|
||||
|
||||
**Decoder (`rlnc_decoder.ts`):**
|
||||
- Maintains an augmented coefficient matrix in **reduced row-echelon form (RREF)**.
|
||||
- Each incoming symbol is forward-eliminated against existing pivots, then if it has a new pivot:
|
||||
1. Scale the row so pivot = 1
|
||||
2. Eliminate the new pivot from all existing rows
|
||||
3. Insert maintaining pivot-column order
|
||||
- When `rank == K`, the matrix is identity and the RHS data is the reconstructed source symbols.
|
||||
|
||||
### 2. GF(256) Arithmetic (`gf256.ts`)
|
||||
|
||||
- Irreducible polynomial: `x^8 + x^4 + x^3 + x^2 + 1` (0x11d, same as AES).
|
||||
- Pre-computed **log/antilog tables** at module load time for O(1) multiply/divide/inverse.
|
||||
- Addition/subtraction = XOR (same operation in characteristic-2 fields).
|
||||
|
||||
### 3. QR Code Generation (`qr_encode.ts`, `frame_raster.ts`)
|
||||
|
||||
- Uses `qrcode-generator` library to produce boolean module matrices.
|
||||
- Capacity is computed from an embedded RS block table (versions 1–40, all ECC levels).
|
||||
- `rasterizeQR()` scales each module to `scale × scale` pixels and adds a 4-module white quiet zone.
|
||||
- Output is pure black/white RGBA `ImageData`.
|
||||
|
||||
### 4. QR Decoding (`qr_decode.ts`)
|
||||
|
||||
- Thin wrapper around `jsQR`.
|
||||
- `jsQR` internally converts RGBA → grayscale and applies adaptive thresholding (8×8 regions with 5×5 averaging). No external preprocessing is needed.
|
||||
- For camera scanning we pass `inversionAttempts: 'attemptBoth'` (handles glare/reflections). For GIF file mode we use `'dontInvert'` (our QRs are black-on-white, giving ~50% speedup).
|
||||
|
||||
### 5. GIF Encoding (`gif_render.ts`)
|
||||
|
||||
- Uses `gifenc` with a **2-colour global palette** (white, black).
|
||||
- Each frame is converted from RGBA to indexed (threshold at 50% brightness).
|
||||
- The NETSCAPE 2.0 extension sets loop count to infinity.
|
||||
- Default delay: 200 ms per frame (5 fps).
|
||||
|
||||
### 6. Frame Scheduling (`scheduler.ts`)
|
||||
|
||||
- Systematic symbols are interleaved across generations first, then coded symbols.
|
||||
- Generation order is deterministically shuffled using `totalGenerations` as a seed.
|
||||
- This spreads redundancy evenly: if you watch any prefix of the GIF, you see some symbols from every generation.
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Sender
|
||||
|
||||
```
|
||||
Text or File
|
||||
│
|
||||
▼
|
||||
[Wrap metadata if file]
|
||||
│
|
||||
▼
|
||||
[Optional deflate compression (fflate)]
|
||||
│
|
||||
▼
|
||||
Split into 201-byte symbols
|
||||
│
|
||||
▼
|
||||
Group into generations of K=16
|
||||
│
|
||||
▼
|
||||
RLNC encode each generation → 16 systematic + 8 coded symbols
|
||||
│
|
||||
▼
|
||||
Build packets (8-byte header + payload + CRC32C)
|
||||
│
|
||||
▼
|
||||
Schedule frames (interleave systematic, then coded, shuffle generations)
|
||||
│
|
||||
▼
|
||||
Rasterize each packet to QR code (V10-M, scale=3, 4-module quiet zone)
|
||||
│
|
||||
▼
|
||||
Encode frames into animated GIF (2-colour palette, 200 ms delay)
|
||||
│
|
||||
▼
|
||||
Blob URL → <img> preview + download
|
||||
```
|
||||
|
||||
### Receiver
|
||||
|
||||
```
|
||||
Camera frames or GIF file
|
||||
│
|
||||
▼
|
||||
[If camera: software crop center 50% (2× zoom), optional camera zoom API]
|
||||
│
|
||||
▼
|
||||
Decode QR with jsQR → raw bytes
|
||||
│
|
||||
▼
|
||||
Parse packet (verify magic, verify CRC32C)
|
||||
│
|
||||
▼
|
||||
Deduplicate by (generationIndex, symbolIndex)
|
||||
│
|
||||
▼
|
||||
Feed to RLNC decoder (systematic or coded based on symbolIndex)
|
||||
│
|
||||
▼
|
||||
When rank == K for a generation → mark solved
|
||||
│
|
||||
▼
|
||||
When all generations solved:
|
||||
│
|
||||
├── Text mode → decompress → TextDecoder → show in <textarea>
|
||||
│
|
||||
└── File mode → decompress → strip metadata → Blob + download link
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Why a single hardcoded profile?
|
||||
|
||||
Sender and receiver are the same codebase. There is no need for profile negotiation, manifest parsing, or version selection. Removing the manifest and session ID simplified the packet format to a compact 8-byte header.
|
||||
|
||||
### Why RLNC instead of simple repetition?
|
||||
|
||||
Simple repetition (send every packet N times) is easy but wasteful. RLNC means **any K linearly independent symbols** decode a generation - you don't need specific ones. This maximizes the information content of every received frame.
|
||||
|
||||
### Why V10 instead of larger versions?
|
||||
|
||||
Larger QR codes (V31, V40) hold more data but require higher camera resolution and sharper focus. V10 is small enough to be readable by average phone cameras at screen distance while still carrying 213 bytes per frame.
|
||||
|
||||
### Why GIF instead of MP4/WebM?
|
||||
|
||||
GIF is universally supported, requires no codecs, and every frame is a full still image (no inter-frame compression artifacts). The 2-colour palette gives excellent LZW compression (~10:1 vs raw RGBA).
|
||||
|
||||
### Why Web Workers?
|
||||
|
||||
- **Encode worker:** packetization + scheduling is CPU-bound and blocks the main thread for large files.
|
||||
- **GIF worker:** GIF encoding (LZW) is CPU-bound.
|
||||
- **Decode worker:** RLNC Gaussian elimination and QR decoding run at 5 fps and must not freeze the UI.
|
||||
|
||||
### Why floor(3%) for outer RS instead of always having parity?
|
||||
|
||||
Outer RS overhead is `Math.floor(sourceGenerations × 0.03)`:
|
||||
- **G ≤ 33:** 0 parity generations - small files recover fast (no wasted round-robin slots).
|
||||
- **G ≥ 34:** 1+ parity generations - protects against whole-generation loss (e.g. a camera burst-drop at the wrong moment).
|
||||
|
||||
Using `Math.floor` (not `Math.ceil`) ensures small files genuinely get zero parity. At G=34 the overhead is ~3% as intended.
|
||||
|
||||
### Why neededPackets = K × totalGenerations instead of K × sourceGenerations?
|
||||
|
||||
The frame scheduler interleaves symbols round-robin across **all** generations (source + parity). You can't receive packets selectively - every cycle of the GIF gives one symbol to each generation. So the practical minimum to decode is `K × totalGenerations`, which accounts for the interleaving overhead. This makes the progress indicator match reality (e.g. 2 source + 1 parity = 48 needed, not 32).
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Swapped arguments to `generateCoefficients`
|
||||
|
||||
```ts
|
||||
// WRONG - causes massive array allocation and browser hang
|
||||
generateCoefficients(seed, 16)
|
||||
|
||||
// CORRECT
|
||||
generateCoefficients(16, seed)
|
||||
```
|
||||
|
||||
### Stale blob URLs in the sender
|
||||
|
||||
If `gifUrl` (a `blob:` URL) is not revoked with `URL.revokeObjectURL()` before generating a new GIF, the browser throws an "object error" when the old `<img>` tries to re-render with a revoked URL. Always call `revokeObjectURL()` in a reset helper.
|
||||
|
||||
### Wrong dimensions passed to `createQRGif()`
|
||||
|
||||
`createQRGif()` expects the actual image width/height in pixels, **not** the raw buffer byte length. A common bug is passing `rgba.length` (which is `width × height × 4`) as the width parameter.
|
||||
|
||||
### Deterministic vs random frame loss in tests
|
||||
|
||||
Never use `Math.random()` for frame-loss simulation in tests - it causes flaky failures due to shared RNG state across test files. Use a deterministic pattern like `(i + 1) % 5 !== 0`.
|
||||
|
||||
### `transfer` list type mismatch
|
||||
|
||||
When passing `ArrayBuffer` via `postMessage` with a transfer list, TypeScript may complain about `ArrayBufferLike` vs `ArrayBuffer`. Cast explicitly: `finalData.buffer as ArrayBuffer`.
|
||||
* core: protocol, packetization, FEC, reconstruction
|
||||
* wasm: generated RaptorQ artifact verification
|
||||
* cli: terminal raster and CLI encode pipeline
|
||||
* web: browser QR/GIF/ZXing/worker integration
|
||||
|
||||
@@ -1,239 +1,105 @@
|
||||
# QR Stream
|
||||
# RaptorQR
|
||||
|
||||
Transfer files and text between devices by displaying an animated sequence of QR codes and reading it with a camera.
|
||||
Transfer files and text between devices by displaying high-throughput animated QR codes and reading them with a camera.
|
||||
|
||||
No network, no Bluetooth, no cables. Everything runs locally in the browser or terminal.
|
||||
Everything runs locally in the browser or terminal: no upload server, no Bluetooth, no cable.
|
||||
|
||||
**Live demo:** https://qr.linkto.host/
|
||||
Live demo: https://qr.linkto.host/
|
||||
|
||||
---
|
||||
## Packages
|
||||
|
||||
```text
|
||||
packages/raptorqr-core protocol, packetization, FEC, QR encode/decode APIs
|
||||
packages/raptorqr-wasm fast_qr and RaptorQ WASM artifacts plus Colab scripts
|
||||
packages/raptorqr-cli raptorqr terminal CLI
|
||||
apps/web Preact/Vite web app
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
* Works fully offline after loading the web app
|
||||
* No server, no telemetry, no cloud upload
|
||||
* Send and receive text or files in the browser
|
||||
* Send files or stdin from the CLI
|
||||
* Preserves filenames for file transfers
|
||||
* Instantly displays received text
|
||||
* Compresses data before transfer, typically saving 3x-10x bandwidth on text files
|
||||
* Uses RaptorQ fountain coding for efficient recovery from dropped frames
|
||||
* Uses CRC32 checksums for consistency checks
|
||||
* Supports GIF export for sharing or embedding QR streams
|
||||
* Live QR playback is rendered with Canvas, so transfer can start immediately without waiting for GIF generation
|
||||
* Adjustable QR size, playback interval, scan sampling rate, QR ECC level, and encoding redundancy
|
||||
* Browser sender/receiver for text and file transfer
|
||||
* Terminal sender via the `raptorqr` CLI
|
||||
* Primary RaptorQ WASM fountain codec
|
||||
* Deprecated JS RLNC compatible codec kept for explicit comparison and old flows
|
||||
* fast_qr WASM QR rendering, plus ZXing WASM writer option
|
||||
* ZXing WASM QR scanning with configurable decoder settings
|
||||
* Parallel QR playback, live Canvas rendering, and optional GIF export
|
||||
* Adjustable QR version, ECC level, playback FPS, scan FPS, and repair overhead
|
||||
|
||||
---
|
||||
## Development
|
||||
|
||||
## Performance
|
||||
|
||||
QR Stream has been significantly optimized compared with the original JavaScript-only implementation.
|
||||
|
||||
In measured tests, the new pipeline reaches **50x+ higher throughput** in practical transfer scenarios.
|
||||
|
||||
Measured examples:
|
||||
|
||||
| Scenario | Result |
|
||||
| ---------------------------------------- | -----------------------------: |
|
||||
| V20 QR, 4-code parallel playback, 30 FPS | up to 300 decoded QR symbols/s |
|
||||
| V30 QR | 100+ decoded QR symbols/s |
|
||||
| 95.2 KB file transfer (V30-L x 4QR@30fps)| 375 ms, about 254.0 KB/s |
|
||||
| 3.0 MB file transfer (V30-L x 4QR@15fps)| about 45 KB/s |
|
||||
|
||||
The 95.2 KB & 3.0MB file test was measured on **iPhone 16 + Safari**. Actual speed depends on device camera quality, browser performance, lighting, QR size, QR version, playback rate, and scan settings.
|
||||
|
||||
The new version also supports larger file transfers more reliably, including fixes for the previous `GF(256) division by zero` issue and other algorithmic edge cases.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### pnpm
|
||||
|
||||
```bash
|
||||
pnpm add -g qr-stream
|
||||
```
|
||||
|
||||
You can also run it directly without installing:
|
||||
|
||||
```bash
|
||||
pnpm dlx qr-stream [file]
|
||||
```
|
||||
|
||||
### npm
|
||||
|
||||
```bash
|
||||
npm install -g qr-stream
|
||||
npx qr-stream [file]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI Usage
|
||||
|
||||
### Encode text or a file into a looping QR sequence
|
||||
|
||||
```bash
|
||||
# Read from file
|
||||
qr-stream document.pdf
|
||||
|
||||
# Read from stdin
|
||||
echo "Hello, world!" | qr-stream
|
||||
|
||||
# Pipe file contents
|
||||
base64 image.png | qr-stream
|
||||
```
|
||||
|
||||
The terminal clears, enters an alternate screen buffer, and displays the QR frames in a loop.
|
||||
|
||||
Press **q** or **Ctrl-C** to quit.
|
||||
|
||||
### Start the web app preview server
|
||||
|
||||
```bash
|
||||
qr-stream --serve # start web app preview server
|
||||
qr-stream --serve --port 8080 # custom port
|
||||
qr-stream --serve --host 127.0.0.1 # localhost only
|
||||
```
|
||||
|
||||
Serves the built web UI.
|
||||
|
||||
The default port is `3000`, and the default host is `0.0.0.0`.
|
||||
|
||||
You can also set the port with the `PORT` environment variable:
|
||||
|
||||
```bash
|
||||
PORT=8080 qr-stream --serve
|
||||
```
|
||||
|
||||
The server resolves the `dist/` directory automatically, so it works from both the bundled CLI and a local checkout.
|
||||
|
||||
### CLI flags
|
||||
|
||||
| Flag | Description |
|
||||
| --------------- | ----------------------------------------------------------------- |
|
||||
| `-h`, `--help` | Show usage information |
|
||||
| `-s`, `--serve` | Start the web preview server |
|
||||
| `--port <n>` | TCP port for `--serve`, default: `3000`, also supports `PORT` env |
|
||||
| `--host <ip>` | Bind address for `--serve`, default: `0.0.0.0` |
|
||||
|
||||
---
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* [Node.js](https://nodejs.org/) >= 18
|
||||
* [pnpm](https://pnpm.io/)
|
||||
|
||||
### Install dependencies
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Start the dev server
|
||||
Run the web app:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
pnpm dev:web
|
||||
```
|
||||
|
||||
Starts Vite with hot reload on:
|
||||
|
||||
```text
|
||||
http://localhost:5173
|
||||
```
|
||||
|
||||
### Build
|
||||
Build everything:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Produces:
|
||||
|
||||
* `dist/index.html` and `dist/assets/*` - the web app
|
||||
* `dist/qr-stream.js` - the self-contained CLI bundle
|
||||
|
||||
### Preview the production build
|
||||
|
||||
```bash
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
Serves the contents of `dist/` locally exactly as it will run in production.
|
||||
|
||||
### Run tests
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
Runs the full test suite via Vitest.
|
||||
|
||||
### Run the CLI from source
|
||||
Run the CLI from source:
|
||||
|
||||
```bash
|
||||
pnpm cli
|
||||
pnpm --filter @raptorqr/cli cli
|
||||
```
|
||||
|
||||
### Build the CLI only
|
||||
Smoke-test the built CLI:
|
||||
|
||||
```bash
|
||||
pnpm build:cli
|
||||
node packages/raptorqr-cli/dist/raptorqr.js --help
|
||||
```
|
||||
|
||||
---
|
||||
## CLI
|
||||
|
||||
## RaptorQ WASM
|
||||
```bash
|
||||
raptorqr document.pdf
|
||||
echo "Hello, world!" | raptorqr
|
||||
raptorqr --serve --port 8080
|
||||
```
|
||||
|
||||
QR Stream now uses a standard RaptorQ fountain-code implementation based on the Rust `cberner/raptorq` implementation.
|
||||
|
||||
The Rust implementation is compiled to WASM and used by the web app and CLI for the high-performance encoding/decoding path.
|
||||
|
||||
The previous JavaScript fountain-code implementation is still kept as a compatibility fallback.
|
||||
|
||||
The RaptorQ source and build script live under:
|
||||
The CLI bundle is built at:
|
||||
|
||||
```text
|
||||
src/raptorq/
|
||||
packages/raptorqr-cli/dist/raptorqr.js
|
||||
```
|
||||
|
||||
When changing the Rust implementation, rebuild the WASM package from that directory before rebuilding the main app.
|
||||
The CLI copies its required WASM sidecars into the same `dist/` directory.
|
||||
|
||||
---
|
||||
## WASM Artifacts
|
||||
|
||||
The generated artifacts live under:
|
||||
|
||||
```text
|
||||
packages/raptorqr-wasm/src/fast_qr/wasm
|
||||
packages/raptorqr-wasm/src/raptorq/wasm
|
||||
```
|
||||
|
||||
The Colab build scripts are:
|
||||
|
||||
```text
|
||||
packages/raptorqr-wasm/src/fast_qr/build_fast_qr_wasm_colab.py
|
||||
packages/raptorqr-wasm/src/raptorq/build_raptorq_wasm_colab.py
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
QR Stream is built around a camera-friendly, loss-tolerant transfer pipeline:
|
||||
The protocol keeps the existing fixed 8-byte transport header. RaptorQ packets use the reserved symbol index sentinel, while JS RLNC packets use the legacy symbol index range.
|
||||
|
||||
1. The sender compresses the input data.
|
||||
2. The data is encoded with RaptorQ fountain coding.
|
||||
3. Encoded packets are rendered as QR codes.
|
||||
4. The live view draws QR frames directly to Canvas, so playback can begin immediately.
|
||||
5. The receiver scans frames with ZXing WASM.
|
||||
6. Decoded packets are collected until enough symbols are available.
|
||||
7. The receiver reconstructs the original payload and verifies it with CRC32.
|
||||
`wasm-raptorq` is the default FEC codec. `js-rlnc` is still exported and test-covered, but it is deprecated and is never used as an automatic fallback.
|
||||
|
||||
The scanner was migrated from `jsQR` to `ZXing WASM`, which improves scan throughput and reliability.
|
||||
|
||||
GIF generation is now separated from live QR playback. This means the live transfer path no longer needs to wait for GIF generation before starting, while GIF export remains available as a separate sharing/export feature.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Sender** compresses your data, splits it into encoded packets, and wraps each packet in a QR code.
|
||||
2. The QR codes are shown as an animated sequence in the terminal or browser.
|
||||
3. **Receiver** scans the sequence with a camera or uploads a GIF.
|
||||
4. The receiver decodes the frames and reassembles the original file or text.
|
||||
|
||||
The protocol uses RaptorQ fountain coding so the transfer can survive dropped frames, glare, and partial obstruction without requiring every single QR frame to be scanned.
|
||||
|
||||
For a deep dive into the packet format, algorithms, and design decisions, see **[ARCHITECTURE.md](ARCHITECTURE.md)**.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT - built for fun and utility.
|
||||
For a deeper protocol and package overview, see [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #0d1117; }
|
||||
</style>
|
||||
<title>QR-over-GIF Transfer</title>
|
||||
<title>RaptorQR</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@raptorqr/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "RaptorQR Preact web app",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@raptorqr/core": "workspace:*",
|
||||
"@raptorqr/wasm": "workspace:*",
|
||||
"fflate": "^0.8.2",
|
||||
"preact": "10.29.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "2"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* file download, and text display.
|
||||
*/
|
||||
import { useState, useCallback, useRef, useEffect } from 'preact/hooks';
|
||||
import { parseGif, renderGifFrame } from '@/core/gif/gif_parser';
|
||||
import { parseGif, renderGifFrame } from '@raptorqr/core/gif/gif_parser';
|
||||
import {
|
||||
BINARIZER_OPTIONS,
|
||||
DECODE_PRESETS,
|
||||
@@ -16,13 +16,14 @@ import {
|
||||
type MaxSymbolsMode,
|
||||
type QrBinarizer,
|
||||
type QrDecodeSettings,
|
||||
} from '@/core/qr/decode_settings';
|
||||
} from '@raptorqr/core/qr/decode_settings';
|
||||
import {
|
||||
DEFAULT_RECEIVER_FEC_CODEC,
|
||||
FEC_CODECS,
|
||||
formatFecCodec,
|
||||
normalizeReceiverFecCodec,
|
||||
type ReceiverFecCodec,
|
||||
} from '@/core/fec/codec';
|
||||
} from '@raptorqr/core/fec/codec';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -42,7 +43,10 @@ const MAX_SCAN_RATE_FPS = 60;
|
||||
const DEFAULT_SCAN_RATE_FPS = 60;
|
||||
const DECODE_RATE_WINDOW_MS = 1000;
|
||||
const DECODE_PRESET_OPTIONS: DecodePresetId[] = ['fast', 'balance', 'robust', 'custom'];
|
||||
const RECEIVER_FEC_CODEC_OPTIONS: ReceiverFecCodec[] = ['auto', 'js-rlnc', 'wasm-raptorq'];
|
||||
const RECEIVER_FEC_CODEC_OPTIONS: ReceiverFecCodec[] = [
|
||||
'auto',
|
||||
...FEC_CODECS.map((codec) => codec.id),
|
||||
];
|
||||
|
||||
const S = {
|
||||
section: {
|
||||
@@ -2,14 +2,14 @@
|
||||
* Sender page — text/file input, live QR playback, and GIF export.
|
||||
*/
|
||||
import { useState, useCallback, useEffect, useRef } from 'preact/hooks';
|
||||
import type { EccLevel } from '@/core/qr/qr_encode';
|
||||
import type { EccLevel } from '@raptorqr/core/qr/qr_encode';
|
||||
import {
|
||||
DEFAULT_QR_ENCODER,
|
||||
QR_ENCODERS,
|
||||
formatQREncoder,
|
||||
normalizeQREncoder,
|
||||
type QREncoder,
|
||||
} from '@/core/qr/qr_encoder_browser';
|
||||
} from '@raptorqr/core/qr/qr_encoder_browser';
|
||||
import {
|
||||
DEFAULT_QR_ECC_LEVEL,
|
||||
DEFAULT_QR_VERSION,
|
||||
@@ -18,23 +18,24 @@ import {
|
||||
createQRTransferProfile,
|
||||
type QRTransferProfile,
|
||||
type QRVersionOption,
|
||||
} from '@/core/protocol/profiles';
|
||||
} from '@raptorqr/core/protocol/profiles';
|
||||
import {
|
||||
stripedFrameCount,
|
||||
stripedPacketIndex,
|
||||
type ParallelQRCount,
|
||||
} from '@/core/sender/parallel_striping';
|
||||
} from '@raptorqr/core/sender/parallel_striping';
|
||||
import {
|
||||
DEFAULT_FEC_CODEC,
|
||||
DEFAULT_RAPTORQ_REPAIR_PERCENT,
|
||||
FEC_CODECS,
|
||||
MAX_RAPTORQ_REPAIR_PERCENT,
|
||||
MIN_RAPTORQ_REPAIR_PERCENT,
|
||||
formatFecCodec,
|
||||
normalizeFecCodec,
|
||||
normalizeRaptorQRepairPercent,
|
||||
type FecCodec,
|
||||
} from '@/core/fec/codec';
|
||||
import { QrWorkerPool } from '@/core/qr/qr_worker_pool';
|
||||
} from '@raptorqr/core/fec/codec';
|
||||
import { QrWorkerPool } from '@/lib/qr_worker_pool';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -87,7 +88,7 @@ const MAX_FRAME_RATE_FPS = 60;
|
||||
const DEFAULT_FRAME_RATE_FPS = 30;
|
||||
const DEFAULT_PARALLEL_QR_COUNT: ParallelQRCount = 4;
|
||||
const PARALLEL_QR_COUNTS: ParallelQRCount[] = [1, 2, 4, 6, 8];
|
||||
const FEC_CODEC_OPTIONS: FecCodec[] = ['wasm-raptorq', 'js-rlnc'];
|
||||
const FEC_CODEC_OPTIONS: FecCodec[] = FEC_CODECS.map((codec) => codec.id);
|
||||
const LIVE_TARGET_PX = 360;
|
||||
const QR_QUIET_ZONE_MODULES = 4;
|
||||
const FRAME_CACHE_LIMIT = 240;
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EccLevel } from '@/core/qr/qr_encode';
|
||||
import type { QREncoder } from '@/core/qr/qr_encoder_browser';
|
||||
import type { EccLevel } from '@raptorqr/core/qr/qr_encode';
|
||||
import type { QREncoder } from '@raptorqr/core/qr/qr_encoder_browser';
|
||||
|
||||
type RenderWorkerMessage =
|
||||
| {
|
||||
@@ -2,12 +2,12 @@
|
||||
* Frame decode test: verify QR encode -> decode roundtrip at the packet level.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { packetize } from '@/core/sender/packetizer';
|
||||
import { scheduleFrames } from '@/core/sender/scheduler';
|
||||
import { renderQRCodeImageData } from '@/core/qr/qr_encoder_browser';
|
||||
import { decodeQRFromCanvas } from '@/core/qr/qr_decode';
|
||||
import { parsePacket } from '@/core/protocol/packet';
|
||||
import { QR_VERSION, ECC_LEVEL } from '@/core/protocol/constants';
|
||||
import { packetize } from '@raptorqr/core/sender/packetizer';
|
||||
import { scheduleFrames } from '@raptorqr/core/sender/scheduler';
|
||||
import { renderQRCodeImageData } from '@raptorqr/core/qr/qr_encoder_browser';
|
||||
import { decodeQRFromCanvas } from '@raptorqr/core/qr/qr_decode';
|
||||
import { parsePacket } from '@raptorqr/core/protocol/packet';
|
||||
import { QR_VERSION, ECC_LEVEL } from '@raptorqr/core/protocol/constants';
|
||||
|
||||
describe('Frame Decode', () => {
|
||||
it('should decode every frame in a small transmission', async () => {
|
||||
@@ -2,16 +2,16 @@
|
||||
* GIF roundtrip: encode data into GIF frames, parse them back, decode.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { packetize } from '@/core/sender/packetizer';
|
||||
import { scheduleFrames } from '@/core/sender/scheduler';
|
||||
import { renderQRCodeImageData } from '@/core/qr/qr_encoder_browser';
|
||||
import { createQRGif } from '@/core/gif/gif_render';
|
||||
import { parseGif, renderGifFrame } from '@/core/gif/gif_parser';
|
||||
import { decodeQRFromCanvas } from '@/core/qr/qr_decode';
|
||||
import { parsePacket } from '@/core/protocol/packet';
|
||||
import { GenerationDecoder } from '@/core/fec/rlnc_decoder';
|
||||
import { assemblePayload } from '@/core/reconstruct/assemble';
|
||||
import { K, MAX_PAYLOAD_SIZE, QR_VERSION, ECC_LEVEL, FRAME_DELAY_MS } from '@/core/protocol/constants';
|
||||
import { packetize } from '@raptorqr/core/sender/packetizer';
|
||||
import { scheduleFrames } from '@raptorqr/core/sender/scheduler';
|
||||
import { renderQRCodeImageData } from '@raptorqr/core/qr/qr_encoder_browser';
|
||||
import { createQRGif } from '@raptorqr/core/gif/gif_render';
|
||||
import { parseGif, renderGifFrame } from '@raptorqr/core/gif/gif_parser';
|
||||
import { decodeQRFromCanvas } from '@raptorqr/core/qr/qr_decode';
|
||||
import { parsePacket } from '@raptorqr/core/protocol/packet';
|
||||
import { GenerationDecoder } from '@raptorqr/core/fec/rlnc_decoder';
|
||||
import { assemblePayload } from '@raptorqr/core/reconstruct/assemble';
|
||||
import { K, MAX_PAYLOAD_SIZE, QR_VERSION, ECC_LEVEL, FRAME_DELAY_MS } from '@raptorqr/core/protocol/constants';
|
||||
|
||||
describe('GIF Roundtrip', () => {
|
||||
it('should encode and decode a GIF', async () => {
|
||||
@@ -2,15 +2,15 @@
|
||||
* Production roundtrip test: mimics the production app flow.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { packetize } from '@/core/sender/packetizer';
|
||||
import { scheduleFrames } from '@/core/sender/scheduler';
|
||||
import { renderQRCodeImageData } from '@/core/qr/qr_encoder_browser';
|
||||
import { createQRGif } from '@/core/gif/gif_render';
|
||||
import { parseGif, renderGifFrame } from '@/core/gif/gif_parser';
|
||||
import { decodeQRFromCanvas } from '@/core/qr/qr_decode';
|
||||
import { parsePacket } from '@/core/protocol/packet';
|
||||
import { GenerationDecoder } from '@/core/fec/rlnc_decoder';
|
||||
import { assemblePayload } from '@/core/reconstruct/assemble';
|
||||
import { packetize } from '@raptorqr/core/sender/packetizer';
|
||||
import { scheduleFrames } from '@raptorqr/core/sender/scheduler';
|
||||
import { renderQRCodeImageData } from '@raptorqr/core/qr/qr_encoder_browser';
|
||||
import { createQRGif } from '@raptorqr/core/gif/gif_render';
|
||||
import { parseGif, renderGifFrame } from '@raptorqr/core/gif/gif_parser';
|
||||
import { decodeQRFromCanvas } from '@raptorqr/core/qr/qr_decode';
|
||||
import { parsePacket } from '@raptorqr/core/protocol/packet';
|
||||
import { GenerationDecoder } from '@raptorqr/core/fec/rlnc_decoder';
|
||||
import { assemblePayload } from '@raptorqr/core/reconstruct/assemble';
|
||||
import { inflateSync } from 'fflate';
|
||||
import {
|
||||
K,
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
QR_VERSION,
|
||||
ECC_LEVEL,
|
||||
FRAME_DELAY_MS,
|
||||
} from '@/core/protocol/constants';
|
||||
} from '@raptorqr/core/protocol/constants';
|
||||
|
||||
describe('Production Roundtrip', () => {
|
||||
it('should transfer a binary payload via GIF with frame loss', async () => {
|
||||
@@ -2,7 +2,9 @@
|
||||
// This is a minimal implementation sufficient for tests
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join, normalize, parse, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { expect } from 'vitest';
|
||||
|
||||
class MockImageData {
|
||||
@@ -34,8 +36,9 @@ class MockImageData {
|
||||
globalThis.ImageData = MockImageData;
|
||||
|
||||
const originalFetch = globalThis.fetch?.bind(globalThis);
|
||||
const repoRoot = findRepoRoot(dirname(fileURLToPath(import.meta.url)));
|
||||
const zxingReaderWasmPath = join(
|
||||
process.cwd(),
|
||||
repoRoot,
|
||||
'node_modules',
|
||||
'zxing-wasm',
|
||||
'dist',
|
||||
@@ -43,7 +46,7 @@ const zxingReaderWasmPath = join(
|
||||
'zxing_reader.wasm',
|
||||
);
|
||||
const zxingWriterWasmPath = join(
|
||||
process.cwd(),
|
||||
repoRoot,
|
||||
'node_modules',
|
||||
'zxing-wasm',
|
||||
'dist',
|
||||
@@ -51,16 +54,20 @@ const zxingWriterWasmPath = join(
|
||||
'zxing_writer.wasm',
|
||||
);
|
||||
const raptorqWasmPath = join(
|
||||
process.cwd(),
|
||||
repoRoot,
|
||||
'packages',
|
||||
'raptorqr-wasm',
|
||||
'src',
|
||||
'raptorq',
|
||||
'wasm',
|
||||
'qrstream_raptorq_wasm_bg.wasm',
|
||||
);
|
||||
const fastQrWasmPath = join(
|
||||
process.cwd(),
|
||||
repoRoot,
|
||||
'packages',
|
||||
'raptorqr-wasm',
|
||||
'src',
|
||||
'fast_qr_wasm',
|
||||
'fast_qr',
|
||||
'wasm',
|
||||
'qrstream_fast_qr_wasm_bg.wasm',
|
||||
);
|
||||
@@ -77,40 +84,24 @@ globalThis.fetch = async (input, init) => {
|
||||
? String(input)
|
||||
: input.url;
|
||||
|
||||
if (process.env.RAPTORQR_DEBUG_FETCH === '1') {
|
||||
console.log('test fetch', url);
|
||||
}
|
||||
|
||||
if (url.includes('zxing_reader.wasm')) {
|
||||
const bytes = await readFile(zxingReaderWasmPath);
|
||||
const body = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
return new Response(body, {
|
||||
headers: { 'Content-Type': 'application/wasm' },
|
||||
status: 200,
|
||||
});
|
||||
return wasmResponse(url, zxingReaderWasmPath);
|
||||
}
|
||||
|
||||
if (url.includes('zxing_writer.wasm')) {
|
||||
const bytes = await readFile(zxingWriterWasmPath);
|
||||
const body = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
return new Response(body, {
|
||||
headers: { 'Content-Type': 'application/wasm' },
|
||||
status: 200,
|
||||
});
|
||||
return wasmResponse(url, zxingWriterWasmPath);
|
||||
}
|
||||
|
||||
if (url.includes('qrstream_raptorq_wasm_bg.wasm')) {
|
||||
const bytes = await readFile(raptorqWasmPath);
|
||||
const body = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
return new Response(body, {
|
||||
headers: { 'Content-Type': 'application/wasm' },
|
||||
status: 200,
|
||||
});
|
||||
return wasmResponse(url, raptorqWasmPath);
|
||||
}
|
||||
|
||||
if (url.includes('qrstream_fast_qr_wasm_bg.wasm')) {
|
||||
const bytes = await readFile(fastQrWasmPath);
|
||||
const body = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
return new Response(body, {
|
||||
headers: { 'Content-Type': 'application/wasm' },
|
||||
status: 200,
|
||||
});
|
||||
return wasmResponse(url, fastQrWasmPath);
|
||||
}
|
||||
|
||||
if (!originalFetch) {
|
||||
@@ -118,3 +109,34 @@ globalThis.fetch = async (input, init) => {
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
|
||||
function findRepoRoot(startDir: string): string {
|
||||
let dir = resolve(startDir);
|
||||
const root = parse(dir).root;
|
||||
|
||||
while (true) {
|
||||
if (existsSync(join(dir, 'pnpm-workspace.yaml'))) {
|
||||
return dir;
|
||||
}
|
||||
if (dir === root) return process.cwd();
|
||||
dir = dirname(dir);
|
||||
}
|
||||
}
|
||||
|
||||
async function wasmResponse(url: string, fallbackPath: string): Promise<Response> {
|
||||
const localPath = viteFsPath(url);
|
||||
const bytes = await readFile(localPath && existsSync(localPath) ? localPath : fallbackPath);
|
||||
const body = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
return new Response(body, {
|
||||
headers: { 'Content-Type': 'application/wasm' },
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
|
||||
function viteFsPath(url: string): string | null {
|
||||
const marker = '/@fs/';
|
||||
const idx = url.indexOf(marker);
|
||||
if (idx < 0) return null;
|
||||
const rawPath = decodeURIComponent(url.slice(idx + marker.length));
|
||||
return normalize(rawPath);
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
import {
|
||||
getMaxByteCapacity,
|
||||
getMaxZXingWriterByteCapacity,
|
||||
} from '../core/qr/qr_encode.ts';
|
||||
import { rasterizeQR, rasterizeToGrayscale, getRasterDimensions } from '../core/qr/frame_raster.ts';
|
||||
import { decodeQRFromBuffer, decodeQRCodesFromCanvas } from '../core/qr/qr_decode.ts';
|
||||
} from '@raptorqr/core/qr/qr_encode';
|
||||
import { rasterizeQR, rasterizeToGrayscale, getRasterDimensions } from '@raptorqr/core/qr/frame_raster';
|
||||
import { decodeQRFromBuffer, decodeQRCodesFromCanvas } from '@raptorqr/core/qr/qr_decode';
|
||||
import {
|
||||
DEFAULT_QR_ENCODER,
|
||||
QR_ENCODERS,
|
||||
encodeQRCodeMatrix,
|
||||
renderQRCodeImageData,
|
||||
} from '../core/qr/qr_encoder_browser.ts';
|
||||
} from '@raptorqr/core/qr/qr_encoder_browser';
|
||||
import {
|
||||
QrRenderer,
|
||||
ensureFastQrWasm,
|
||||
getFastQrWasmMemory,
|
||||
isFastQrAvailable,
|
||||
} from '../core/qr/fast_qr_wasm.ts';
|
||||
import { renderQRCodeImageDataWithZXing } from '../core/qr/qr_write_wasm.ts';
|
||||
import { createQRGif, estimateGifSize } from '../core/gif/gif_render.ts';
|
||||
} from '@raptorqr/core/qr/fast_qr_wasm';
|
||||
import { renderQRCodeImageDataWithZXing } from '@raptorqr/core/qr/qr_write_wasm';
|
||||
import { createQRGif, estimateGifSize } from '@raptorqr/core/gif/gif_render';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
function matrixFromModuleBytes(modules: Uint8Array, sideModules: number): boolean[][] {
|
||||
@@ -53,7 +53,7 @@ describe('QR encode', () => {
|
||||
});
|
||||
|
||||
it('should only apply ZXing writer binary ECI overhead to ZXing transfer profiles', async () => {
|
||||
const { createQRTransferProfile, getQRTransferProfile } = await import('@/core/protocol/profiles');
|
||||
const { createQRTransferProfile, getQRTransferProfile } = await import('@raptorqr/core/protocol/profiles');
|
||||
|
||||
expect(getMaxByteCapacity(20, 'L')).toBe(858);
|
||||
expect(getMaxZXingWriterByteCapacity(20, 'L')).toBe(856);
|
||||
@@ -66,7 +66,7 @@ describe('QR encode', () => {
|
||||
});
|
||||
|
||||
it('should expose low-ECC transfer profiles with more payload room', async () => {
|
||||
const { getQRTransferProfile } = await import('@/core/protocol/profiles');
|
||||
const { getQRTransferProfile } = await import('@raptorqr/core/protocol/profiles');
|
||||
|
||||
const medium = getQRTransferProfile('v20-m');
|
||||
const low = getQRTransferProfile('v20-l');
|
||||
@@ -156,7 +156,7 @@ describe('QR encode', () => {
|
||||
});
|
||||
|
||||
it('should write and read a full V40-L transfer packet with fast_qr WASM', async () => {
|
||||
const { createQRTransferProfile } = await import('@/core/protocol/profiles');
|
||||
const { createQRTransferProfile } = await import('@raptorqr/core/protocol/profiles');
|
||||
|
||||
const profile = createQRTransferProfile(40, 'L', 'fast-qr-wasm');
|
||||
const packet = new Uint8Array(profile.maxPacketSize);
|
||||
@@ -248,7 +248,7 @@ describe('Frame raster', () => {
|
||||
});
|
||||
|
||||
it('should write and read a full V20-L transfer packet with ZXing WASM', async () => {
|
||||
const { createQRTransferProfile } = await import('@/core/protocol/profiles');
|
||||
const { createQRTransferProfile } = await import('@raptorqr/core/protocol/profiles');
|
||||
|
||||
const profile = createQRTransferProfile(20, 'L', 'zxing-wasm');
|
||||
const packet = new Uint8Array(profile.maxPacketSize);
|
||||
@@ -287,7 +287,7 @@ describe('GIF render', () => {
|
||||
|
||||
describe('Parallel striping', () => {
|
||||
it('should assign each packet once per loop and leave incomplete tail tiles empty', async () => {
|
||||
const { stripedFrameCount, stripedPacketIndex } = await import('@/core/sender/parallel_striping');
|
||||
const { stripedFrameCount, stripedPacketIndex } = await import('@raptorqr/core/sender/parallel_striping');
|
||||
|
||||
const packetCount = 10;
|
||||
const parallelCount = 4;
|
||||
@@ -312,7 +312,7 @@ describe('Parallel striping', () => {
|
||||
});
|
||||
|
||||
it('should support 8-way striping without duplicating packets', async () => {
|
||||
const { stripedFrameCount, stripedPacketIndex } = await import('@/core/sender/parallel_striping');
|
||||
const { stripedFrameCount, stripedPacketIndex } = await import('@raptorqr/core/sender/parallel_striping');
|
||||
|
||||
const packetCount = 17;
|
||||
const parallelCount = 8;
|
||||
@@ -339,8 +339,8 @@ describe('Parallel striping', () => {
|
||||
|
||||
describe('Transfer defaults', () => {
|
||||
it('should default RaptorQ repair to 10 percent and expose manual 6/8 decode symbols', async () => {
|
||||
const { DEFAULT_RAPTORQ_REPAIR_PERCENT, normalizeFecCodec } = await import('@/core/fec/codec');
|
||||
const { MAX_SYMBOL_OPTIONS, normalizeDecodeSettings } = await import('@/core/qr/decode_settings');
|
||||
const { DEFAULT_RAPTORQ_REPAIR_PERCENT, normalizeFecCodec } = await import('@raptorqr/core/fec/codec');
|
||||
const { MAX_SYMBOL_OPTIONS, normalizeDecodeSettings } = await import('@raptorqr/core/qr/decode_settings');
|
||||
|
||||
expect(DEFAULT_RAPTORQ_REPAIR_PERCENT).toBe(10);
|
||||
expect(normalizeFecCodec('js-rlnc')).toBe('js-rlnc');
|
||||
@@ -9,23 +9,23 @@ import { inflateSync } from 'fflate';
|
||||
import {
|
||||
decodeQRCodesFromCanvas,
|
||||
type QrDecodeResult,
|
||||
} from '@/core/qr/qr_decode';
|
||||
} from '@raptorqr/core/qr/qr_decode';
|
||||
import {
|
||||
DEFAULT_DECODE_SETTINGS,
|
||||
normalizeDecodeSettings,
|
||||
type QrDecodeSettings,
|
||||
} from '@/core/qr/decode_settings';
|
||||
import { packetCodec, parsePacket, type TransportCodec } from '@/core/protocol/packet';
|
||||
import type { Packet } from '@/core/protocol/packet';
|
||||
import { K, sourceGenerationsFromDataLength } from '@/core/protocol/constants';
|
||||
} from '@raptorqr/core/qr/decode_settings';
|
||||
import { packetCodec, parsePacket, type TransportCodec } from '@raptorqr/core/protocol/packet';
|
||||
import type { Packet } from '@raptorqr/core/protocol/packet';
|
||||
import { K, sourceGenerationsFromDataLength } from '@raptorqr/core/protocol/constants';
|
||||
import {
|
||||
DEFAULT_RECEIVER_FEC_CODEC,
|
||||
normalizeReceiverFecCodec,
|
||||
type ReceiverFecCodec,
|
||||
} from '@/core/fec/codec';
|
||||
import { RaptorQWasmDecoder } from '@/core/fec/raptorq_wasm';
|
||||
import { GenerationDecoder } from '@/core/fec/rlnc_decoder';
|
||||
import { assemblePayload } from '@/core/reconstruct/assemble';
|
||||
} from '@raptorqr/core/fec/codec';
|
||||
import { RaptorQWasmDecoder } from '@raptorqr/core/fec/raptorq_wasm';
|
||||
import { GenerationDecoder } from '@raptorqr/core/fec/rlnc_decoder';
|
||||
import { assemblePayload } from '@raptorqr/core/reconstruct/assemble';
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { packetize } from '@/core/sender/packetizer';
|
||||
import { packetizeRaptorQ } from '@/core/sender/raptorq_packetizer';
|
||||
import { scheduleFrames } from '@/core/sender/scheduler';
|
||||
import { packetize } from '@raptorqr/core/sender/packetizer';
|
||||
import { packetizeRaptorQ } from '@raptorqr/core/sender/raptorq_packetizer';
|
||||
import { scheduleFrames } from '@raptorqr/core/sender/scheduler';
|
||||
import {
|
||||
DEFAULT_RAPTORQ_REPAIR_PERCENT,
|
||||
normalizeFecCodec,
|
||||
normalizeRaptorQRepairPercent,
|
||||
type FecCodec,
|
||||
} from '@/core/fec/codec';
|
||||
} from '@raptorqr/core/fec/codec';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { EccLevel } from '@/core/qr/qr_encode';
|
||||
import type { EccLevel } from '@raptorqr/core/qr/qr_encode';
|
||||
import {
|
||||
normalizeQREncoder,
|
||||
renderQRCodeImageData,
|
||||
type QREncoder,
|
||||
} from '@/core/qr/qr_encoder_browser';
|
||||
import { createQRGif } from '@/core/gif/gif_render';
|
||||
import { QR_VERSION, ECC_LEVEL, FRAME_DELAY_MS } from '@/core/protocol/constants';
|
||||
} from '@raptorqr/core/qr/qr_encoder_browser';
|
||||
import { createQRGif } from '@raptorqr/core/gif/gif_render';
|
||||
import { QR_VERSION, ECC_LEVEL, FRAME_DELAY_MS } from '@raptorqr/core/protocol/constants';
|
||||
import {
|
||||
stripedFrameCount,
|
||||
stripedPacketIndex,
|
||||
type ParallelQRCount,
|
||||
} from '@/core/sender/parallel_striping';
|
||||
} from '@raptorqr/core/sender/parallel_striping';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
* Fast path:
|
||||
* fast_qr WASM writes RGBA directly into its fixed buffer.
|
||||
*
|
||||
* Fallback:
|
||||
* - if fast_qr_wasm is selected but unavailable, fall back to JS matrix raster.
|
||||
* - otherwise use the selected browser QR encoder.
|
||||
* If fast_qr_wasm is selected but unavailable, report a controlled error.
|
||||
* Otherwise use the selected browser QR encoder.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -15,13 +14,13 @@ import {
|
||||
getFastQrWasmMemory,
|
||||
QrRenderer,
|
||||
fastQrUnavailableMessage,
|
||||
} from '@/core/qr/fast_qr_wasm';
|
||||
} from '@raptorqr/core/qr/fast_qr_wasm';
|
||||
import {
|
||||
normalizeQREncoder,
|
||||
renderQRCodeImageData,
|
||||
type QREncoder,
|
||||
} from '@/core/qr/qr_encoder_browser';
|
||||
import type { EccLevel } from '@/core/qr/qr_encode';
|
||||
} from '@raptorqr/core/qr/qr_encoder_browser';
|
||||
import type { EccLevel } from '@raptorqr/core/qr/qr_encode';
|
||||
|
||||
export interface RenderRequest {
|
||||
type: 'render';
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import preact from '@preact/preset-vite';
|
||||
import { resolve } from 'path';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
+10
-42
@@ -1,35 +1,18 @@
|
||||
{
|
||||
"name": "@hermitm0nk/qr-stream",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.8.4",
|
||||
"description": "Transfer files and text via animated QR codes - CLI and web app",
|
||||
"name": "raptorqr",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "High-throughput QR transfer toolkit and web app",
|
||||
"type": "module",
|
||||
"main": "dist/qr-stream.js",
|
||||
"bin": {
|
||||
"qr-stream": "dist/qr-stream.js"
|
||||
},
|
||||
"files": [
|
||||
"dist/",
|
||||
"src/",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && pnpm run build:cli",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"verify:raptorq-wasm": "node scripts/verify-raptorq-wasm.mjs",
|
||||
"cli": "bun run src/cli/qr-stream.ts",
|
||||
"cli:file": "bun run src/cli/qr-stream.ts",
|
||||
"build:cli": "esbuild src/cli/qr-stream.ts --bundle --platform=node --target=node18 --outfile=dist/qr-stream.js --format=esm --external:node:* && node scripts/copy-cli-wasm-assets.mjs",
|
||||
"prepublishOnly": "pnpm run build"
|
||||
"dev:web": "pnpm --filter @raptorqr/web dev",
|
||||
"build": "pnpm --filter @raptorqr/wasm build && pnpm --filter @raptorqr/core build && pnpm --filter @raptorqr/cli build && pnpm --filter @raptorqr/web build",
|
||||
"test": "pnpm --filter @raptorqr/wasm test && pnpm --filter @raptorqr/core test && pnpm --filter @raptorqr/cli test && pnpm --filter @raptorqr/web test",
|
||||
"test:watch": "pnpm --filter @raptorqr/web test:watch",
|
||||
"verify:raptorq-wasm": "pnpm --filter @raptorqr/wasm verify:raptorq"
|
||||
},
|
||||
"keywords": [
|
||||
"qr",
|
||||
@@ -39,19 +22,10 @@
|
||||
"file-transfer",
|
||||
"animated",
|
||||
"gif",
|
||||
"rlnc",
|
||||
"raptorq",
|
||||
"fountain-code"
|
||||
],
|
||||
"author": "Hermit",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/hermitm0nk/qr-stream.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/hermitm0nk/qr-stream/issues"
|
||||
},
|
||||
"homepage": "https://github.com/hermitm0nk/qr-stream#readme",
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "2",
|
||||
"@types/bun": "latest",
|
||||
@@ -60,11 +34,5 @@
|
||||
"typescript": "5.7.3",
|
||||
"vite": "6",
|
||||
"vitest": "3"
|
||||
},
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2",
|
||||
"gifenc": "^1.0.3",
|
||||
"preact": "10.29.4",
|
||||
"zxing-wasm": "^3.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@raptorqr/cli",
|
||||
"version": "0.1.0",
|
||||
"description": "RaptorQR command line interface",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"raptorqr": "dist/raptorqr.js"
|
||||
},
|
||||
"files": [
|
||||
"dist/",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"dependencies": {
|
||||
"@raptorqr/core": "workspace:*",
|
||||
"@raptorqr/wasm": "workspace:*"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "esbuild src/raptorqr.ts --bundle --platform=node --target=node18 --outfile=dist/raptorqr.js --format=esm --external:node:* && node scripts/copy-wasm-assets.mjs",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"cli": "bun run src/raptorqr.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { copyFileSync, mkdirSync } from 'node:fs';
|
||||
import { basename, dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const packageRoot = resolve(here, '..');
|
||||
const repoRoot = resolve(packageRoot, '..', '..');
|
||||
const dist = resolve(packageRoot, 'dist');
|
||||
|
||||
const assets = [
|
||||
{
|
||||
source: resolve(repoRoot, 'packages', 'raptorqr-wasm', 'src', 'fast_qr', 'wasm', 'qrstream_fast_qr_wasm_bg.wasm'),
|
||||
target: resolve(dist, 'qrstream_fast_qr_wasm_bg.wasm'),
|
||||
},
|
||||
{
|
||||
source: resolve(repoRoot, 'packages', 'raptorqr-wasm', 'src', 'raptorq', 'wasm', 'qrstream_raptorq_wasm_bg.wasm'),
|
||||
target: resolve(dist, 'qrstream_raptorq_wasm_bg.wasm'),
|
||||
},
|
||||
];
|
||||
|
||||
mkdirSync(dist, { recursive: true });
|
||||
|
||||
for (const asset of assets) {
|
||||
copyFileSync(asset.source, asset.target);
|
||||
console.log(`copied ${basename(asset.target)}`);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* QR Stream CLI
|
||||
* RaptorQR CLI
|
||||
*
|
||||
* Displays a sequence of QR codes in the terminal for text/file transfer.
|
||||
* Reads text from stdin or a file argument, encodes it using the same
|
||||
@@ -8,19 +8,19 @@
|
||||
* interrupted.
|
||||
*
|
||||
* Usage:
|
||||
* qr-stream [file] # read from file
|
||||
* echo "text" | qr-stream # read from stdin
|
||||
* npx qr-stream [file] # via npx
|
||||
* bunx qr-stream [file] # via bunx
|
||||
* qr-stream --serve # start web app preview server
|
||||
* raptorqr [file] # read from file
|
||||
* echo "text" | raptorqr # read from stdin
|
||||
* npx raptorqr [file] # via npx
|
||||
* bunx raptorqr [file] # via bunx
|
||||
* raptorqr --serve # start web app preview server
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync, openSync, closeSync } from 'fs';
|
||||
import { ReadStream } from 'tty';
|
||||
import { encodeQRCodeMatrix } from '../core/qr/qr_encoder_node';
|
||||
import { packetize } from '../core/sender/packetizer';
|
||||
import { scheduleFrames } from '../core/sender/scheduler';
|
||||
import { QR_VERSION, ECC_LEVEL } from '../core/protocol/constants';
|
||||
import { encodeQRCodeMatrix } from '@raptorqr/core/node';
|
||||
import { packetize } from '@raptorqr/core/sender/packetizer';
|
||||
import { scheduleFrames } from '@raptorqr/core/sender/scheduler';
|
||||
import { QR_VERSION, ECC_LEVEL } from '@raptorqr/core/protocol/constants';
|
||||
import {
|
||||
enterAltBuffer,
|
||||
exitAltBuffer,
|
||||
@@ -39,14 +39,14 @@ const FPS_MS = 100;
|
||||
// ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const HELP_TEXT = `
|
||||
QR Stream – encode text or a file into a looping QR-code sequence.
|
||||
RaptorQR – encode text or a file into a looping QR-code sequence.
|
||||
|
||||
Usage:
|
||||
qr-stream [file] read from file
|
||||
echo "text" | qr-stream read from stdin
|
||||
npx qr-stream [file] via npx
|
||||
bunx qr-stream [file] via bunx
|
||||
qr-stream --serve start web app preview server
|
||||
raptorqr [file] read from file
|
||||
echo "text" | raptorqr read from stdin
|
||||
npx raptorqr [file] via npx
|
||||
bunx raptorqr [file] via bunx
|
||||
raptorqr --serve start web app preview server
|
||||
|
||||
Server flags (with --serve):
|
||||
--port <n> TCP port (default: 3000, also: PORT env)
|
||||
@@ -56,7 +56,7 @@ Controls:
|
||||
q, Q quit
|
||||
Ctrl-C quit
|
||||
|
||||
The app uses the same V10-M QR protocol as the web transfer demo.
|
||||
The app uses the same transfer protocol as the web sender.
|
||||
`;
|
||||
|
||||
function showHelp(): void {
|
||||
@@ -25,14 +25,17 @@ function findWebRoot(): string {
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
let dir = dirname(scriptPath);
|
||||
|
||||
// If we are inside dist/ (bundled), serve from dist/
|
||||
// If published assets are copied next to the bundled CLI, serve them.
|
||||
if (basename(dir) === 'dist') {
|
||||
return dir;
|
||||
const siblingWebDist = join(dir, 'web');
|
||||
if (existsSync(join(siblingWebDist, 'index.html'))) {
|
||||
return siblingWebDist;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise search upward for dist/index.html
|
||||
// Otherwise search upward for the monorepo web app build.
|
||||
while (dir !== dirname(dir)) {
|
||||
const candidate = join(dir, 'dist');
|
||||
const candidate = join(dir, 'apps', 'web', 'dist');
|
||||
if (existsSync(join(candidate, 'index.html'))) {
|
||||
return candidate;
|
||||
}
|
||||
@@ -40,8 +43,8 @@ function findWebRoot(): string {
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Could not find built web assets (dist/index.html). ' +
|
||||
'Run `npm run build` first.'
|
||||
'Could not find built RaptorQR web assets (apps/web/dist/index.html). ' +
|
||||
'Run `pnpm build` first.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,7 +105,7 @@ export function startServer(port: number, host?: string): Server {
|
||||
|
||||
server.listen(port, host ?? '0.0.0.0', () => {
|
||||
const addr = host ?? '0.0.0.0';
|
||||
console.log(`QR Stream web app serving at http://${addr}:${port}`);
|
||||
console.log(`RaptorQR web app serving at http://${addr}:${port}`);
|
||||
});
|
||||
|
||||
return server;
|
||||
+30
-30
@@ -15,7 +15,7 @@ function stripAnsi(str: string): string {
|
||||
|
||||
describe('Terminal Rasterizer', () => {
|
||||
it('should render a simple 2×2 matrix with default quiet zone', async () => {
|
||||
const { renderToTerminal } = await import('@/cli/terminal_raster');
|
||||
const { renderToTerminal } = await import('../terminal_raster');
|
||||
|
||||
const matrix = [
|
||||
[true, true],
|
||||
@@ -32,7 +32,7 @@ describe('Terminal Rasterizer', () => {
|
||||
});
|
||||
|
||||
it('should render mixed 4×4 matrix with default quiet zone', async () => {
|
||||
const { renderToTerminal } = await import('@/cli/terminal_raster');
|
||||
const { renderToTerminal } = await import('../terminal_raster');
|
||||
|
||||
const matrix = [
|
||||
[true, false, true, false],
|
||||
@@ -59,7 +59,7 @@ describe('Terminal Rasterizer', () => {
|
||||
});
|
||||
|
||||
it('should handle odd number of QR rows', async () => {
|
||||
const { renderToTerminal } = await import('@/cli/terminal_raster');
|
||||
const { renderToTerminal } = await import('../terminal_raster');
|
||||
|
||||
const matrix = [
|
||||
[true, false, true],
|
||||
@@ -77,7 +77,7 @@ describe('Terminal Rasterizer', () => {
|
||||
});
|
||||
|
||||
it('should render an all-white matrix', async () => {
|
||||
const { renderToTerminal } = await import('@/cli/terminal_raster');
|
||||
const { renderToTerminal } = await import('../terminal_raster');
|
||||
|
||||
const matrix = [
|
||||
[false, false],
|
||||
@@ -90,7 +90,7 @@ describe('Terminal Rasterizer', () => {
|
||||
});
|
||||
|
||||
it('should render a full-block matrix', async () => {
|
||||
const { renderToTerminal } = await import('@/cli/terminal_raster');
|
||||
const { renderToTerminal } = await import('../terminal_raster');
|
||||
|
||||
const matrix = [
|
||||
[true, true],
|
||||
@@ -103,7 +103,7 @@ describe('Terminal Rasterizer', () => {
|
||||
});
|
||||
|
||||
it('should support custom quiet zone size', async () => {
|
||||
const { renderToTerminal } = await import('@/cli/terminal_raster');
|
||||
const { renderToTerminal } = await import('../terminal_raster');
|
||||
|
||||
const matrix = [
|
||||
[true, true],
|
||||
@@ -118,7 +118,7 @@ describe('Terminal Rasterizer', () => {
|
||||
});
|
||||
|
||||
it('should support zero quiet zone', async () => {
|
||||
const { renderToTerminal } = await import('@/cli/terminal_raster');
|
||||
const { renderToTerminal } = await import('../terminal_raster');
|
||||
|
||||
const matrix = [
|
||||
[true, true],
|
||||
@@ -131,7 +131,7 @@ describe('Terminal Rasterizer', () => {
|
||||
});
|
||||
|
||||
it('should wrap each line with ANSI colour codes', async () => {
|
||||
const { renderToTerminal } = await import('@/cli/terminal_raster');
|
||||
const { renderToTerminal } = await import('../terminal_raster');
|
||||
|
||||
const matrix = [
|
||||
[true, true],
|
||||
@@ -149,17 +149,17 @@ describe('Terminal Rasterizer', () => {
|
||||
|
||||
describe('CLI Screen Helpers', () => {
|
||||
it('should expose clearScreen', async () => {
|
||||
const { clearScreen } = await import('@/cli/terminal_raster');
|
||||
const { clearScreen } = await import('../terminal_raster');
|
||||
expect(typeof clearScreen).toBe('function');
|
||||
});
|
||||
|
||||
it('should expose moveCursorUp', async () => {
|
||||
const { moveCursorUp } = await import('@/cli/terminal_raster');
|
||||
const { moveCursorUp } = await import('../terminal_raster');
|
||||
expect(typeof moveCursorUp).toBe('function');
|
||||
});
|
||||
|
||||
it('should expose alt-buffer helpers', async () => {
|
||||
const { enterAltBuffer, exitAltBuffer } = await import('@/cli/terminal_raster');
|
||||
const { enterAltBuffer, exitAltBuffer } = await import('../terminal_raster');
|
||||
expect(typeof enterAltBuffer).toBe('function');
|
||||
expect(typeof exitAltBuffer).toBe('function');
|
||||
});
|
||||
@@ -168,18 +168,18 @@ describe('CLI Screen Helpers', () => {
|
||||
describe('CLI Help Flag', () => {
|
||||
it('should not throw when help text is constructed', () => {
|
||||
const helpText = `
|
||||
QR Stream \u2013 encode text or a file into a looping QR-code sequence.
|
||||
RaptorQR \u2013 encode text or a file into a looping QR-code sequence.
|
||||
|
||||
Usage:
|
||||
qr-stream [file] read from file
|
||||
echo "text" | qr-stream read from stdin
|
||||
qr-stream --serve start web app preview server
|
||||
raptorqr [file] read from file
|
||||
echo "text" | raptorqr read from stdin
|
||||
raptorqr --serve start web app preview server
|
||||
|
||||
Controls:
|
||||
q, Q quit
|
||||
Ctrl-C quit
|
||||
|
||||
The app uses the same V10-M QR protocol as the web transfer demo.
|
||||
The app uses the same transfer protocol as the web sender.
|
||||
`;
|
||||
expect(helpText).toContain('Usage:');
|
||||
expect(helpText).toContain('quit');
|
||||
@@ -188,15 +188,15 @@ The app uses the same V10-M QR protocol as the web transfer demo.
|
||||
|
||||
describe('CLI Encoder Pipeline', () => {
|
||||
it('should produce the same frames as the web app (reuse common logic)', async () => {
|
||||
const { packetize } = await import('@/core/sender/packetizer');
|
||||
const { scheduleFrames } = await import('@/core/sender/scheduler');
|
||||
const { packetize } = await import('@raptorqr/core/sender/packetizer');
|
||||
const { scheduleFrames } = await import('@raptorqr/core/sender/scheduler');
|
||||
const {
|
||||
DEFAULT_CLI_QR_ENCODER,
|
||||
encodeQRCodeMatrix,
|
||||
isFastQrNodeAvailable,
|
||||
} = await import('@/core/qr/qr_encoder_node');
|
||||
const { QR_VERSION, ECC_LEVEL } = await import('@/core/protocol/constants');
|
||||
const { parseHeader } = await import('@/core/protocol/packet');
|
||||
} = await import('@raptorqr/core/node');
|
||||
const { QR_VERSION, ECC_LEVEL } = await import('@raptorqr/core/protocol/constants');
|
||||
const { parseHeader } = await import('@raptorqr/core/protocol/packet');
|
||||
|
||||
const data = new TextEncoder().encode('CLI test payload for verifying protocol reuse. '.repeat(3));
|
||||
const result = packetize(data, false, true);
|
||||
@@ -220,8 +220,8 @@ describe('CLI Encoder Pipeline', () => {
|
||||
|
||||
describe('CLI Frame Cycle', () => {
|
||||
it('should loop through frames deterministically', async () => {
|
||||
const { packetize } = await import('@/core/sender/packetizer');
|
||||
const { scheduleFrames } = await import('@/core/sender/scheduler');
|
||||
const { packetize } = await import('@raptorqr/core/sender/packetizer');
|
||||
const { scheduleFrames } = await import('@raptorqr/core/sender/scheduler');
|
||||
|
||||
const data = new TextEncoder().encode('Frame cycle test — small payload');
|
||||
const result = packetize(data, false, false);
|
||||
@@ -241,10 +241,10 @@ describe('CLI Frame Cycle', () => {
|
||||
});
|
||||
|
||||
it('should generate valid QR matrix for every scheduled frame', async () => {
|
||||
const { packetize } = await import('@/core/sender/packetizer');
|
||||
const { scheduleFrames } = await import('@/core/sender/scheduler');
|
||||
const { encodeQRCodeMatrix } = await import('@/core/qr/qr_encoder_node');
|
||||
const { QR_VERSION, ECC_LEVEL } = await import('@/core/protocol/constants');
|
||||
const { packetize } = await import('@raptorqr/core/sender/packetizer');
|
||||
const { scheduleFrames } = await import('@raptorqr/core/sender/scheduler');
|
||||
const { encodeQRCodeMatrix } = await import('@raptorqr/core/node');
|
||||
const { QR_VERSION, ECC_LEVEL } = await import('@raptorqr/core/protocol/constants');
|
||||
|
||||
const data = new TextEncoder().encode('Every frame QR test — medium payload');
|
||||
const result = packetize(data, false, true);
|
||||
@@ -262,9 +262,9 @@ describe('CLI Frame Cycle', () => {
|
||||
|
||||
describe('CLI Input Parsing', () => {
|
||||
it('should read file from argument and produce frames', async () => {
|
||||
const { packetize } = await import('@/core/sender/packetizer');
|
||||
const { scheduleFrames } = await import('@/core/sender/scheduler');
|
||||
const { parseHeader } = await import('@/core/protocol/packet');
|
||||
const { packetize } = await import('@raptorqr/core/sender/packetizer');
|
||||
const { scheduleFrames } = await import('@raptorqr/core/sender/scheduler');
|
||||
const { parseHeader } = await import('@raptorqr/core/protocol/packet');
|
||||
|
||||
const fileData = new TextEncoder().encode('file content test');
|
||||
const result = packetize(fileData, true, false);
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['src/tests/**/*.test.ts'],
|
||||
setupFiles: ['../../apps/web/src/tests/setup.ts'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@raptorqr/core",
|
||||
"version": "0.1.0",
|
||||
"description": "Protocol, packetization, FEC, QR encode/decode APIs for RaptorQR",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./browser": {
|
||||
"types": "./src/browser.ts",
|
||||
"import": "./src/browser.ts"
|
||||
},
|
||||
"./node": {
|
||||
"types": "./src/node.ts",
|
||||
"import": "./src/node.ts"
|
||||
},
|
||||
"./fec/*": {
|
||||
"types": "./src/fec/*.ts",
|
||||
"import": "./src/fec/*.ts"
|
||||
},
|
||||
"./gif/*": {
|
||||
"types": "./src/gif/*.ts",
|
||||
"import": "./src/gif/*.ts"
|
||||
},
|
||||
"./preprocess/*": {
|
||||
"types": "./src/preprocess/*.ts",
|
||||
"import": "./src/preprocess/*.ts"
|
||||
},
|
||||
"./protocol/*": {
|
||||
"types": "./src/protocol/*.ts",
|
||||
"import": "./src/protocol/*.ts"
|
||||
},
|
||||
"./qr/*": {
|
||||
"types": "./src/qr/*.ts",
|
||||
"import": "./src/qr/*.ts"
|
||||
},
|
||||
"./reconstruct/*": {
|
||||
"types": "./src/reconstruct/*.ts",
|
||||
"import": "./src/reconstruct/*.ts"
|
||||
},
|
||||
"./sender/*": {
|
||||
"types": "./src/sender/*.ts",
|
||||
"import": "./src/sender/*.ts"
|
||||
},
|
||||
"./wasm/*": {
|
||||
"types": "./src/wasm/*.ts",
|
||||
"import": "./src/wasm/*.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@raptorqr/wasm": "workspace:*",
|
||||
"fflate": "^0.8.2",
|
||||
"gifenc": "^1.0.3",
|
||||
"zxing-wasm": "^3.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './index';
|
||||
export * from './gif/gif_parser';
|
||||
export * from './gif/gif_render';
|
||||
export * from './qr/fast_qr_wasm';
|
||||
export * from './qr/qr_decode';
|
||||
export * from './qr/qr_encoder_browser';
|
||||
export * from './qr/qr_write_wasm';
|
||||
@@ -1,6 +1,28 @@
|
||||
export type FecCodec = 'js-rlnc' | 'wasm-raptorq';
|
||||
export type FecCodec = 'wasm-raptorq' | 'js-rlnc';
|
||||
export type ReceiverFecCodec = 'auto' | FecCodec;
|
||||
|
||||
export interface FecCodecInfo {
|
||||
id: FecCodec;
|
||||
label: string;
|
||||
status: 'primary' | 'deprecated';
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const FEC_CODECS: FecCodecInfo[] = [
|
||||
{
|
||||
id: 'wasm-raptorq',
|
||||
label: 'RaptorQ WASM',
|
||||
status: 'primary',
|
||||
description: 'Primary RaptorQ fountain codec backed by WASM.',
|
||||
},
|
||||
{
|
||||
id: 'js-rlnc',
|
||||
label: 'JS RLNC',
|
||||
status: 'deprecated',
|
||||
description: 'Deprecated compatible RLNC codec retained for comparison and old flows.',
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_FEC_CODEC: FecCodec = 'wasm-raptorq';
|
||||
export const DEFAULT_RECEIVER_FEC_CODEC: ReceiverFecCodec = 'auto';
|
||||
export const DEFAULT_RAPTORQ_REPAIR_PERCENT = 10;
|
||||
@@ -32,5 +54,7 @@ export function normalizeRaptorQRepairPercent(value: unknown): number {
|
||||
|
||||
export function formatFecCodec(value: FecCodec | ReceiverFecCodec): string {
|
||||
if (value === 'auto') return 'Auto';
|
||||
return value === 'wasm-raptorq' ? 'RaptorQ WASM (exp)' : 'JS RLNC (compatible)';
|
||||
return value === 'wasm-raptorq'
|
||||
? 'RaptorQ WASM'
|
||||
: 'JS RLNC (deprecated / compatible)';
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { GF256_RS_MAX_EVALUATION_POINTS } from '@/core/protocol/constants';
|
||||
import { GF256_RS_MAX_EVALUATION_POINTS } from '@raptorqr/core/protocol/constants';
|
||||
import { add, sub, mul, div, inv, pow } from './gf256';
|
||||
|
||||
const PRIMITIVE = 2; // α = 0x02
|
||||
@@ -1,12 +1,12 @@
|
||||
import init, {
|
||||
RaptorQDecoder,
|
||||
encode_packets,
|
||||
} from '@/raptorq/wasm/qrstream_raptorq_wasm.js';
|
||||
} from '@raptorqr/wasm/raptorq';
|
||||
|
||||
let initPromise: Promise<unknown> | null = null;
|
||||
|
||||
export function raptorQUnavailableMessage(): string {
|
||||
return 'RaptorQ WASM artifacts are not installed. Run src/raptorq/build_raptorq_wasm_colab.py in Google Colab, then copy the generated files into src/raptorq/wasm.';
|
||||
return 'RaptorQ WASM artifacts are not installed. Run packages/raptorqr-wasm/src/raptorq/build_raptorq_wasm_colab.py in Google Colab, then copy the generated files into packages/raptorqr-wasm/src/raptorq/wasm.';
|
||||
}
|
||||
|
||||
export async function ensureRaptorQWasm(): Promise<void> {
|
||||
@@ -0,0 +1,14 @@
|
||||
export * from './fec/codec';
|
||||
export * from './protocol/constants';
|
||||
export * from './protocol/crc32c';
|
||||
export * from './protocol/packet';
|
||||
export * from './protocol/profiles';
|
||||
export * from './qr/decode_settings';
|
||||
export * from './qr/frame_raster';
|
||||
export * from './qr/qr_encode';
|
||||
export * from './qr/qr_encoder';
|
||||
export * from './reconstruct/assemble';
|
||||
export * from './sender/packetizer';
|
||||
export * from './sender/parallel_striping';
|
||||
export * from './sender/raptorq_packetizer';
|
||||
export * from './sender/scheduler';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './index';
|
||||
export * from './qr/qr_encoder_node';
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
getMaxByteCapacity,
|
||||
getMaxZXingWriterByteCapacity,
|
||||
type EccLevel,
|
||||
} from '@/core/qr/qr_encode';
|
||||
import { DEFAULT_QR_ENCODER, type QREncoder } from '@/core/qr/qr_encoder';
|
||||
} from '@raptorqr/core/qr/qr_encode';
|
||||
import { DEFAULT_QR_ENCODER, type QREncoder } from '@raptorqr/core/qr/qr_encoder';
|
||||
|
||||
export interface QRTransferProfile {
|
||||
id: string;
|
||||
@@ -12,8 +12,8 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import init, { QrRenderer } from '@/fast_qr_wasm/wasm/qrstream_fast_qr_wasm.js';
|
||||
import type { InitOutput } from '@/fast_qr_wasm/wasm/qrstream_fast_qr_wasm.js';
|
||||
import init, { QrRenderer } from '@raptorqr/wasm/fast-qr';
|
||||
import type { InitOutput } from '@raptorqr/wasm/fast-qr';
|
||||
|
||||
// ─── Module-level singleton ───────────────────────────────────────────────────
|
||||
|
||||
@@ -25,8 +25,8 @@ let wasmOutput: InitOutput | null = null;
|
||||
export function fastQrUnavailableMessage(): string {
|
||||
return (
|
||||
'fast_qr WASM artifacts are not installed. ' +
|
||||
'Run src/fast_qr_wasm/build_fast_qr_wasm_colab.py in Google Colab, ' +
|
||||
'then copy the generated files into src/fast_qr_wasm/wasm.'
|
||||
'Run packages/raptorqr-wasm/src/fast_qr/build_fast_qr_wasm_colab.py in Google Colab, ' +
|
||||
'then copy the generated files into packages/raptorqr-wasm/src/fast_qr/wasm.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
DEFAULT_DECODE_SETTINGS,
|
||||
normalizeDecodeSettings,
|
||||
type QrDecodeSettings,
|
||||
} from '@/core/qr/decode_settings';
|
||||
} from '@raptorqr/core/qr/decode_settings';
|
||||
|
||||
export interface QrDecodeResult {
|
||||
bytes: Uint8Array;
|
||||
@@ -140,7 +140,7 @@ function prepareReader(): Promise<unknown> {
|
||||
preparePromise = Promise.resolve(
|
||||
prepareZXingModule({
|
||||
overrides: {
|
||||
locateFile: (path) => path.endsWith('.wasm') ? zxingReaderWasmUrl : path,
|
||||
locateFile: (path: string) => path.endsWith('.wasm') ? zxingReaderWasmUrl : path,
|
||||
},
|
||||
equalityFn: Object.is,
|
||||
fireImmediately: true,
|
||||
@@ -10,14 +10,14 @@ import {
|
||||
QrRenderer,
|
||||
initSync,
|
||||
type InitOutput,
|
||||
} from '../../fast_qr_wasm/wasm/qrstream_fast_qr_wasm.js';
|
||||
} from '@raptorqr/wasm/fast-qr';
|
||||
import {
|
||||
DEFAULT_QR_ENCODER,
|
||||
formatQREncoder,
|
||||
type QREncoder,
|
||||
} from './qr_encoder';
|
||||
import type { EccLevel } from './qr_encode';
|
||||
import { readNodeWasmAsset } from '@/core/wasm/node_assets';
|
||||
import { readNodeWasmAsset } from '@raptorqr/core/wasm/node_assets';
|
||||
|
||||
export * from './qr_encoder';
|
||||
|
||||
@@ -25,8 +25,9 @@ export const DEFAULT_CLI_QR_ENCODER: QREncoder = DEFAULT_QR_ENCODER;
|
||||
|
||||
const FAST_QR_WASM_ASSET = {
|
||||
distFileName: 'qrstream_fast_qr_wasm_bg.wasm',
|
||||
sourceRelativePath: 'src/fast_qr_wasm/wasm/qrstream_fast_qr_wasm_bg.wasm',
|
||||
envVar: 'QRSTREAM_FAST_QR_WASM',
|
||||
sourceRelativePath: 'packages/raptorqr-wasm/src/fast_qr/wasm/qrstream_fast_qr_wasm_bg.wasm',
|
||||
packageExport: '@raptorqr/wasm/fast-qr/wasm/qrstream_fast_qr_wasm_bg.wasm',
|
||||
envVar: 'RAPTORQR_FAST_QR_WASM',
|
||||
};
|
||||
|
||||
const ECC_TO_NUM: Record<EccLevel, number> = {
|
||||
@@ -104,8 +105,12 @@ function encodeQRCodeMatrixWithFastQr(
|
||||
const eccNum = ECC_TO_NUM[eccLevel];
|
||||
const sideModules = renderer.render_matrix(data, version, eccNum);
|
||||
const byteLen = sideModules * sideModules;
|
||||
const initOutput = fastQrInitOutput;
|
||||
if (!initOutput) {
|
||||
throw new Error('fast_qr WASM is unavailable in Node.');
|
||||
}
|
||||
const modules = new Uint8Array(
|
||||
fastQrInitOutput.memory.buffer,
|
||||
initOutput.memory.buffer,
|
||||
renderer.matrix_ptr(),
|
||||
byteLen,
|
||||
);
|
||||
@@ -16,7 +16,7 @@ import zxingWriterWasmUrl from 'zxing-wasm/writer/zxing_writer.wasm?url';
|
||||
import {
|
||||
getMaxZXingWriterByteCapacity,
|
||||
type EccLevel,
|
||||
} from '@/core/qr/qr_encode';
|
||||
} from '@raptorqr/core/qr/qr_encode';
|
||||
|
||||
let preparePromise: Promise<unknown> | null = null;
|
||||
|
||||
@@ -84,7 +84,7 @@ function prepareWriter(): Promise<unknown> {
|
||||
preparePromise = Promise.resolve(
|
||||
prepareZXingModule({
|
||||
overrides: {
|
||||
locateFile: (path) => path.endsWith('.wasm') ? zxingWriterWasmUrl : path,
|
||||
locateFile: (path: string) => path.endsWith('.wasm') ? zxingWriterWasmUrl : path,
|
||||
},
|
||||
equalityFn: Object.is,
|
||||
fireImmediately: true,
|
||||
+2
-2
@@ -14,8 +14,8 @@ import {
|
||||
parityCount,
|
||||
K,
|
||||
MAX_PAYLOAD_SIZE,
|
||||
} from '@/core/protocol/constants';
|
||||
import { decodeOuterRS } from '@/core/fec/outer_rs';
|
||||
} from '@raptorqr/core/protocol/constants';
|
||||
import { decodeOuterRS } from '@raptorqr/core/fec/outer_rs';
|
||||
|
||||
/**
|
||||
* Assemble the original preprocessed payload from solved RLNC generations.
|
||||
@@ -13,10 +13,10 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { K, R, MAX_PAYLOAD_SIZE, parityCount } from '@/core/protocol/constants';
|
||||
import { PacketHeader, createPacket } from '@/core/protocol/packet';
|
||||
import { encodeGeneration } from '@/core/fec/rlnc_encoder';
|
||||
import { encodeOuterRS } from '@/core/fec/outer_rs';
|
||||
import { K, R, MAX_PAYLOAD_SIZE, parityCount } from '@raptorqr/core/protocol/constants';
|
||||
import { PacketHeader, createPacket } from '@raptorqr/core/protocol/packet';
|
||||
import { encodeGeneration } from '@raptorqr/core/fec/rlnc_encoder';
|
||||
import { encodeOuterRS } from '@raptorqr/core/fec/outer_rs';
|
||||
import { deflateSync } from 'fflate';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
import { RAPTORQ_SYMBOL_INDEX } from '@/core/protocol/constants';
|
||||
import { createPacket, type PacketHeader } from '@/core/protocol/packet';
|
||||
import { encodeRaptorQPackets } from '@/core/fec/raptorq_wasm';
|
||||
import { RAPTORQ_SYMBOL_INDEX } from '@raptorqr/core/protocol/constants';
|
||||
import { createPacket, type PacketHeader } from '@raptorqr/core/protocol/packet';
|
||||
import { encodeRaptorQPackets } from '@raptorqr/core/fec/raptorq_wasm';
|
||||
import {
|
||||
preprocessPayload,
|
||||
type PreprocessResult,
|
||||
} from '@/core/sender/packetizer';
|
||||
} from '@raptorqr/core/sender/packetizer';
|
||||
|
||||
export interface RaptorQPacketizerResult {
|
||||
packets: Uint8Array[];
|
||||
@@ -13,9 +13,9 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { K, R } from '@/core/protocol/constants';
|
||||
import { parseHeader } from '@/core/protocol/packet';
|
||||
import { Xoshiro128 } from '@/core/fec/xoshiro';
|
||||
import { K, R } from '@raptorqr/core/protocol/constants';
|
||||
import { parseHeader } from '@raptorqr/core/protocol/packet';
|
||||
import { Xoshiro128 } from '@raptorqr/core/fec/xoshiro';
|
||||
|
||||
function seededShuffle<T>(arr: readonly T[], seed: number): T[] {
|
||||
const rng = new Xoshiro128(seed);
|
||||
@@ -21,7 +21,7 @@ describe('Protocol Constants', () => {
|
||||
CRC32C_SIZE,
|
||||
Flags,
|
||||
OUTER_EC_OVERHEAD,
|
||||
} = await import('@/core/protocol/constants');
|
||||
} = await import('@raptorqr/core/protocol/constants');
|
||||
|
||||
expect(MAGIC_BYTE).toBe(0x51);
|
||||
expect(QR_VERSION).toBe(10);
|
||||
@@ -46,7 +46,7 @@ describe('Protocol Constants', () => {
|
||||
|
||||
describe('Packet Serialization', () => {
|
||||
it('should create and parse a packet correctly', async () => {
|
||||
const { createPacket, parsePacket } = await import('@/core/protocol/packet');
|
||||
const { createPacket, parsePacket } = await import('@raptorqr/core/protocol/packet');
|
||||
|
||||
const header = {
|
||||
generationIndex: 5,
|
||||
@@ -76,14 +76,14 @@ describe('Packet Serialization', () => {
|
||||
});
|
||||
|
||||
it('should reject a packet with bad magic', async () => {
|
||||
const { parsePacket } = await import('@/core/protocol/packet');
|
||||
const { parsePacket } = await import('@raptorqr/core/protocol/packet');
|
||||
const bad = new Uint8Array(12);
|
||||
bad[0] = 0x00; // wrong magic
|
||||
expect(() => parsePacket(bad)).toThrow('Invalid magic byte');
|
||||
});
|
||||
|
||||
it('should reject a packet with bad CRC', async () => {
|
||||
const { parsePacket } = await import('@/core/protocol/packet');
|
||||
const { parsePacket } = await import('@raptorqr/core/protocol/packet');
|
||||
const packet = new Uint8Array(12);
|
||||
packet[0] = 0x51; // valid magic
|
||||
expect(() => parsePacket(packet)).toThrow('CRC32C mismatch');
|
||||
@@ -94,7 +94,7 @@ describe('Packet Serialization', () => {
|
||||
|
||||
describe('CRC32-C', () => {
|
||||
it('should compute and verify CRC for a packet', async () => {
|
||||
const { crc32c } = await import('@/core/protocol/crc32c');
|
||||
const { crc32c } = await import('@raptorqr/core/protocol/crc32c');
|
||||
const data = new Uint8Array([0x51, 0x02, 0x00, 0x00, 0x00, 0xde, 0xad, 0xbe]);
|
||||
const crc = crc32c(data);
|
||||
expect(typeof crc).toBe('number');
|
||||
@@ -105,7 +105,7 @@ describe('CRC32-C', () => {
|
||||
|
||||
describe('RLNC Encoder', () => {
|
||||
it('should produce K systematic symbols', async () => {
|
||||
const { encodeGeneration } = await import('@/core/fec/rlnc_encoder');
|
||||
const { encodeGeneration } = await import('@raptorqr/core/fec/rlnc_encoder');
|
||||
const symbols = [
|
||||
new Uint8Array([1, 2, 3, 4]),
|
||||
new Uint8Array([5, 6, 7, 8]),
|
||||
@@ -122,7 +122,7 @@ describe('RLNC Encoder', () => {
|
||||
});
|
||||
|
||||
it('coded symbols should be non-zero and different', async () => {
|
||||
const { encodeGeneration } = await import('@/core/fec/rlnc_encoder');
|
||||
const { encodeGeneration } = await import('@raptorqr/core/fec/rlnc_encoder');
|
||||
const symbols = [
|
||||
new Uint8Array([10, 20]),
|
||||
new Uint8Array([30, 40]),
|
||||
@@ -137,7 +137,7 @@ describe('RLNC Encoder', () => {
|
||||
});
|
||||
|
||||
it('should generate reproducible coefficients', async () => {
|
||||
const { generateCoefficients, deriveCoefficientSeed } = await import('@/core/fec/rlnc_encoder');
|
||||
const { generateCoefficients, deriveCoefficientSeed } = await import('@raptorqr/core/fec/rlnc_encoder');
|
||||
const seed = deriveCoefficientSeed(5, 0);
|
||||
const coeffs1 = generateCoefficients(16, seed);
|
||||
const coeffs2 = generateCoefficients(16, seed);
|
||||
@@ -150,8 +150,8 @@ describe('RLNC Encoder', () => {
|
||||
|
||||
describe('RLNC Decoder', () => {
|
||||
it('should decode from systematic symbols', async () => {
|
||||
const { GenerationDecoder } = await import('@/core/fec/rlnc_decoder');
|
||||
const { encodeGeneration } = await import('@/core/fec/rlnc_encoder');
|
||||
const { GenerationDecoder } = await import('@raptorqr/core/fec/rlnc_decoder');
|
||||
const { encodeGeneration } = await import('@raptorqr/core/fec/rlnc_encoder');
|
||||
|
||||
const symbols = [
|
||||
new Uint8Array([1, 2, 3]),
|
||||
@@ -174,8 +174,8 @@ describe('RLNC Decoder', () => {
|
||||
});
|
||||
|
||||
it('should decode from coded symbols', async () => {
|
||||
const { GenerationDecoder } = await import('@/core/fec/rlnc_decoder');
|
||||
const { encodeGeneration } = await import('@/core/fec/rlnc_encoder');
|
||||
const { GenerationDecoder } = await import('@raptorqr/core/fec/rlnc_decoder');
|
||||
const { encodeGeneration } = await import('@raptorqr/core/fec/rlnc_encoder');
|
||||
|
||||
const symbols = [
|
||||
new Uint8Array([7, 8, 9]),
|
||||
@@ -198,8 +198,8 @@ describe('RLNC Decoder', () => {
|
||||
});
|
||||
|
||||
it('should handle out-of-order symbols', async () => {
|
||||
const { GenerationDecoder } = await import('@/core/fec/rlnc_decoder');
|
||||
const { encodeGeneration } = await import('@/core/fec/rlnc_encoder');
|
||||
const { GenerationDecoder } = await import('@raptorqr/core/fec/rlnc_decoder');
|
||||
const { encodeGeneration } = await import('@raptorqr/core/fec/rlnc_encoder');
|
||||
|
||||
const symbols = [
|
||||
new Uint8Array([1, 1]),
|
||||
@@ -225,8 +225,8 @@ describe('RLNC Decoder', () => {
|
||||
});
|
||||
|
||||
it('should track rank incrementally', async () => {
|
||||
const { GenerationDecoder } = await import('@/core/fec/rlnc_decoder');
|
||||
const { encodeGeneration } = await import('@/core/fec/rlnc_encoder');
|
||||
const { GenerationDecoder } = await import('@raptorqr/core/fec/rlnc_decoder');
|
||||
const { encodeGeneration } = await import('@raptorqr/core/fec/rlnc_encoder');
|
||||
|
||||
const symbols = [new Uint8Array([1, 2]), new Uint8Array([3, 4])];
|
||||
const encoded = encodeGeneration(symbols, 2, 2, 0);
|
||||
@@ -244,7 +244,7 @@ describe('RLNC Decoder', () => {
|
||||
|
||||
describe('Outer Reed-Solomon', () => {
|
||||
it('should encode and decode with no loss', async () => {
|
||||
const { encodeOuterRS, decodeOuterRS } = await import('@/core/fec/outer_rs');
|
||||
const { encodeOuterRS, decodeOuterRS } = await import('@raptorqr/core/fec/outer_rs');
|
||||
|
||||
const chunks = [
|
||||
new Uint8Array([1, 2, 3, 4]),
|
||||
@@ -268,7 +268,7 @@ describe('Outer Reed-Solomon', () => {
|
||||
});
|
||||
|
||||
it('should recover one missing source generation', async () => {
|
||||
const { encodeOuterRS, decodeOuterRS } = await import('@/core/fec/outer_rs');
|
||||
const { encodeOuterRS, decodeOuterRS } = await import('@raptorqr/core/fec/outer_rs');
|
||||
|
||||
const chunks = [
|
||||
new Uint8Array([1, 2, 3, 4]),
|
||||
@@ -291,7 +291,7 @@ describe('Outer Reed-Solomon', () => {
|
||||
});
|
||||
|
||||
it('should recover two missing source generations with two parity', async () => {
|
||||
const { encodeOuterRS, decodeOuterRS } = await import('@/core/fec/outer_rs');
|
||||
const { encodeOuterRS, decodeOuterRS } = await import('@raptorqr/core/fec/outer_rs');
|
||||
|
||||
const chunks = [
|
||||
new Uint8Array([1, 2, 3]),
|
||||
@@ -318,7 +318,7 @@ describe('Outer Reed-Solomon', () => {
|
||||
});
|
||||
|
||||
it('should throw when not enough parity available', async () => {
|
||||
const { encodeOuterRS, decodeOuterRS } = await import('@/core/fec/outer_rs');
|
||||
const { encodeOuterRS, decodeOuterRS } = await import('@raptorqr/core/fec/outer_rs');
|
||||
|
||||
const chunks = [
|
||||
new Uint8Array([1, 2, 3]),
|
||||
@@ -334,7 +334,7 @@ describe('Outer Reed-Solomon', () => {
|
||||
});
|
||||
|
||||
it('should reject RS blocks beyond the GF(256) evaluation point limit', async () => {
|
||||
const { encodeOuterRS } = await import('@/core/fec/outer_rs');
|
||||
const { encodeOuterRS } = await import('@raptorqr/core/fec/outer_rs');
|
||||
|
||||
const chunks = Array.from({ length: 255 }, () => new Uint8Array([1]));
|
||||
|
||||
@@ -346,8 +346,8 @@ describe('Outer Reed-Solomon', () => {
|
||||
|
||||
describe('Payload Assembly', () => {
|
||||
it('should assemble exact data with padding trimmed', async () => {
|
||||
const { assemblePayload } = await import('@/core/reconstruct/assemble');
|
||||
const { K, MAX_PAYLOAD_SIZE } = await import('@/core/protocol/constants');
|
||||
const { assemblePayload } = await import('@raptorqr/core/reconstruct/assemble');
|
||||
const { K, MAX_PAYLOAD_SIZE } = await import('@raptorqr/core/protocol/constants');
|
||||
|
||||
// Create proper-sized symbols (K symbols of MAX_PAYLOAD_SIZE bytes each)
|
||||
const g0: Uint8Array[] = [];
|
||||
@@ -383,8 +383,8 @@ describe('Payload Assembly', () => {
|
||||
});
|
||||
|
||||
it('should handle single generation', async () => {
|
||||
const { assemblePayload } = await import('@/core/reconstruct/assemble');
|
||||
const { K, MAX_PAYLOAD_SIZE } = await import('@/core/protocol/constants');
|
||||
const { assemblePayload } = await import('@raptorqr/core/reconstruct/assemble');
|
||||
const { K, MAX_PAYLOAD_SIZE } = await import('@raptorqr/core/protocol/constants');
|
||||
|
||||
const g0: Uint8Array[] = [];
|
||||
for (let i = 0; i < K; i++) g0.push(new Uint8Array(MAX_PAYLOAD_SIZE));
|
||||
@@ -402,8 +402,8 @@ describe('Payload Assembly', () => {
|
||||
});
|
||||
|
||||
it('should reject when not enough generations solved for outer RS recovery', async () => {
|
||||
const { assemblePayload } = await import('@/core/reconstruct/assemble');
|
||||
const { K, MAX_PAYLOAD_SIZE } = await import('@/core/protocol/constants');
|
||||
const { assemblePayload } = await import('@raptorqr/core/reconstruct/assemble');
|
||||
const { K, MAX_PAYLOAD_SIZE } = await import('@raptorqr/core/protocol/constants');
|
||||
|
||||
const g0: Uint8Array[] = [];
|
||||
for (let i = 0; i < K; i++) g0.push(new Uint8Array(MAX_PAYLOAD_SIZE));
|
||||
@@ -421,9 +421,9 @@ describe('Payload Assembly', () => {
|
||||
|
||||
describe('Packetizer', () => {
|
||||
it('should packetize text data', async () => {
|
||||
const { packetize } = await import('@/core/sender/packetizer');
|
||||
const { K } = await import('@/core/protocol/constants');
|
||||
const { parseHeader } = await import('@/core/protocol/packet');
|
||||
const { packetize } = await import('@raptorqr/core/sender/packetizer');
|
||||
const { K } = await import('@raptorqr/core/protocol/constants');
|
||||
const { parseHeader } = await import('@raptorqr/core/protocol/packet');
|
||||
|
||||
const text = 'Hello, World!';
|
||||
const data = new TextEncoder().encode(text);
|
||||
@@ -448,8 +448,8 @@ describe('Packetizer', () => {
|
||||
});
|
||||
|
||||
it('should packetize binary data across multiple generations', async () => {
|
||||
const { packetize } = await import('@/core/sender/packetizer');
|
||||
const { MAX_PAYLOAD_SIZE, K } = await import('@/core/protocol/constants');
|
||||
const { packetize } = await import('@raptorqr/core/sender/packetizer');
|
||||
const { MAX_PAYLOAD_SIZE, K } = await import('@raptorqr/core/protocol/constants');
|
||||
|
||||
const data = new Uint8Array(MAX_PAYLOAD_SIZE * K * 2 + 100);
|
||||
crypto.getRandomValues(data);
|
||||
@@ -461,7 +461,7 @@ describe('Packetizer', () => {
|
||||
expect(result.totalGenerations).toBeGreaterThanOrEqual(result.sourceGenerations);
|
||||
expect(result.dataLength).toBe(data.length);
|
||||
|
||||
const { parseHeader } = await import('@/core/protocol/packet');
|
||||
const { parseHeader } = await import('@raptorqr/core/protocol/packet');
|
||||
for (const pkt of result.packets) {
|
||||
const h = parseHeader(pkt);
|
||||
expect(h.totalGenerations).toBe(result.totalGenerations);
|
||||
@@ -470,7 +470,7 @@ describe('Packetizer', () => {
|
||||
});
|
||||
|
||||
it('should compress large data', async () => {
|
||||
const { packetize } = await import('@/core/sender/packetizer');
|
||||
const { packetize } = await import('@raptorqr/core/sender/packetizer');
|
||||
|
||||
const text = 'a'.repeat(1000);
|
||||
const data = new TextEncoder().encode(text);
|
||||
@@ -481,7 +481,7 @@ describe('Packetizer', () => {
|
||||
});
|
||||
|
||||
it('should skip compression when it does not reduce payload size', async () => {
|
||||
const { packetize } = await import('@/core/sender/packetizer');
|
||||
const { packetize } = await import('@raptorqr/core/sender/packetizer');
|
||||
|
||||
let state = 0x12345678;
|
||||
const data = new Uint8Array(4096);
|
||||
@@ -499,13 +499,13 @@ describe('Packetizer', () => {
|
||||
});
|
||||
|
||||
it('should not generate unsafe outer RS parity for very large files', async () => {
|
||||
const { packetize } = await import('@/core/sender/packetizer');
|
||||
const { packetize } = await import('@raptorqr/core/sender/packetizer');
|
||||
const {
|
||||
GF256_RS_MAX_EVALUATION_POINTS,
|
||||
K,
|
||||
MAX_PAYLOAD_SIZE,
|
||||
parityCount,
|
||||
} = await import('@/core/protocol/constants');
|
||||
} = await import('@raptorqr/core/protocol/constants');
|
||||
|
||||
const sourceGenerations = GF256_RS_MAX_EVALUATION_POINTS + 5;
|
||||
const data = new Uint8Array(sourceGenerations * K * MAX_PAYLOAD_SIZE);
|
||||
@@ -523,8 +523,8 @@ describe('Packetizer', () => {
|
||||
|
||||
describe('Scheduler', () => {
|
||||
it('should schedule frames deterministically for same totalGenerations', async () => {
|
||||
const { packetize } = await import('@/core/sender/packetizer');
|
||||
const { scheduleFrames } = await import('@/core/sender/scheduler');
|
||||
const { packetize } = await import('@raptorqr/core/sender/packetizer');
|
||||
const { scheduleFrames } = await import('@raptorqr/core/sender/scheduler');
|
||||
|
||||
const data = new TextEncoder().encode('Test data for scheduling');
|
||||
const result = packetize(data, false, false);
|
||||
@@ -537,10 +537,10 @@ describe('Scheduler', () => {
|
||||
});
|
||||
|
||||
it('should interleave generations round-robin', async () => {
|
||||
const { packetize } = await import('@/core/sender/packetizer');
|
||||
const { scheduleFrames } = await import('@/core/sender/scheduler');
|
||||
const { parseHeader } = await import('@/core/protocol/packet');
|
||||
const { K } = await import('@/core/protocol/constants');
|
||||
const { packetize } = await import('@raptorqr/core/sender/packetizer');
|
||||
const { scheduleFrames } = await import('@raptorqr/core/sender/scheduler');
|
||||
const { parseHeader } = await import('@raptorqr/core/protocol/packet');
|
||||
const { K } = await import('@raptorqr/core/protocol/constants');
|
||||
|
||||
const data = new Uint8Array(3000);
|
||||
crypto.getRandomValues(data);
|
||||
@@ -576,12 +576,12 @@ describe('Scheduler', () => {
|
||||
|
||||
describe('End-to-End', () => {
|
||||
it('should roundtrip a small text message', async () => {
|
||||
const { packetize } = await import('@/core/sender/packetizer');
|
||||
const { scheduleFrames } = await import('@/core/sender/scheduler');
|
||||
const { parsePacket } = await import('@/core/protocol/packet');
|
||||
const { GenerationDecoder } = await import('@/core/fec/rlnc_decoder');
|
||||
const { assemblePayload } = await import('@/core/reconstruct/assemble');
|
||||
const { K, MAX_PAYLOAD_SIZE } = await import('@/core/protocol/constants');
|
||||
const { packetize } = await import('@raptorqr/core/sender/packetizer');
|
||||
const { scheduleFrames } = await import('@raptorqr/core/sender/scheduler');
|
||||
const { parsePacket } = await import('@raptorqr/core/protocol/packet');
|
||||
const { GenerationDecoder } = await import('@raptorqr/core/fec/rlnc_decoder');
|
||||
const { assemblePayload } = await import('@raptorqr/core/reconstruct/assemble');
|
||||
const { K, MAX_PAYLOAD_SIZE } = await import('@raptorqr/core/protocol/constants');
|
||||
|
||||
const text = 'Hello, QR world! 💛';
|
||||
const data = new TextEncoder().encode(text);
|
||||
@@ -618,13 +618,13 @@ describe('End-to-End', () => {
|
||||
});
|
||||
|
||||
it('should roundtrip with a manually selected larger QR profile', async () => {
|
||||
const { packetize } = await import('@/core/sender/packetizer');
|
||||
const { scheduleFrames } = await import('@/core/sender/scheduler');
|
||||
const { parsePacket } = await import('@/core/protocol/packet');
|
||||
const { GenerationDecoder } = await import('@/core/fec/rlnc_decoder');
|
||||
const { assemblePayload } = await import('@/core/reconstruct/assemble');
|
||||
const { getQRTransferProfile } = await import('@/core/protocol/profiles');
|
||||
const { K } = await import('@/core/protocol/constants');
|
||||
const { packetize } = await import('@raptorqr/core/sender/packetizer');
|
||||
const { scheduleFrames } = await import('@raptorqr/core/sender/scheduler');
|
||||
const { parsePacket } = await import('@raptorqr/core/protocol/packet');
|
||||
const { GenerationDecoder } = await import('@raptorqr/core/fec/rlnc_decoder');
|
||||
const { assemblePayload } = await import('@raptorqr/core/reconstruct/assemble');
|
||||
const { getQRTransferProfile } = await import('@raptorqr/core/protocol/profiles');
|
||||
const { K } = await import('@raptorqr/core/protocol/constants');
|
||||
|
||||
const profile = getQRTransferProfile('v20-m');
|
||||
const data = new Uint8Array(profile.maxPayloadSize + 123);
|
||||
@@ -677,12 +677,12 @@ describe('End-to-End', () => {
|
||||
});
|
||||
|
||||
it('should recover from lost frames', async () => {
|
||||
const { packetize } = await import('@/core/sender/packetizer');
|
||||
const { scheduleFrames } = await import('@/core/sender/scheduler');
|
||||
const { parsePacket } = await import('@/core/protocol/packet');
|
||||
const { GenerationDecoder } = await import('@/core/fec/rlnc_decoder');
|
||||
const { assemblePayload } = await import('@/core/reconstruct/assemble');
|
||||
const { K, MAX_PAYLOAD_SIZE } = await import('@/core/protocol/constants');
|
||||
const { packetize } = await import('@raptorqr/core/sender/packetizer');
|
||||
const { scheduleFrames } = await import('@raptorqr/core/sender/scheduler');
|
||||
const { parsePacket } = await import('@raptorqr/core/protocol/packet');
|
||||
const { GenerationDecoder } = await import('@raptorqr/core/fec/rlnc_decoder');
|
||||
const { assemblePayload } = await import('@raptorqr/core/reconstruct/assemble');
|
||||
const { K, MAX_PAYLOAD_SIZE } = await import('@raptorqr/core/protocol/constants');
|
||||
|
||||
const data = new TextEncoder().encode('Surviving frame loss with RLNC!');
|
||||
const result = packetize(data, false, false);
|
||||
@@ -719,12 +719,12 @@ describe('End-to-End', () => {
|
||||
});
|
||||
|
||||
it('should recover with outer RS when some generations are entirely missing', async () => {
|
||||
const { packetize } = await import('@/core/sender/packetizer');
|
||||
const { scheduleFrames } = await import('@/core/sender/scheduler');
|
||||
const { parsePacket } = await import('@/core/protocol/packet');
|
||||
const { GenerationDecoder } = await import('@/core/fec/rlnc_decoder');
|
||||
const { assemblePayload } = await import('@/core/reconstruct/assemble');
|
||||
const { K, MAX_PAYLOAD_SIZE } = await import('@/core/protocol/constants');
|
||||
const { packetize } = await import('@raptorqr/core/sender/packetizer');
|
||||
const { scheduleFrames } = await import('@raptorqr/core/sender/scheduler');
|
||||
const { parsePacket } = await import('@raptorqr/core/protocol/packet');
|
||||
const { GenerationDecoder } = await import('@raptorqr/core/fec/rlnc_decoder');
|
||||
const { assemblePayload } = await import('@raptorqr/core/reconstruct/assemble');
|
||||
const { K, MAX_PAYLOAD_SIZE } = await import('@raptorqr/core/protocol/constants');
|
||||
|
||||
// Large payload (>34 source generations) so outer RS actually creates parity.
|
||||
const data = new TextEncoder().encode(
|
||||
+6
-6
@@ -5,12 +5,12 @@
|
||||
* with outer EC (any G of G+P generations) vs without (all G source gens).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { packetize } from '@/core/sender/packetizer';
|
||||
import { scheduleFrames } from '@/core/sender/scheduler';
|
||||
import { parsePacket } from '@/core/protocol/packet';
|
||||
import { GenerationDecoder } from '@/core/fec/rlnc_decoder';
|
||||
import { assemblePayload } from '@/core/reconstruct/assemble';
|
||||
import { K, MAX_PAYLOAD_SIZE } from '@/core/protocol/constants';
|
||||
import { packetize } from '@raptorqr/core/sender/packetizer';
|
||||
import { scheduleFrames } from '@raptorqr/core/sender/scheduler';
|
||||
import { parsePacket } from '@raptorqr/core/protocol/packet';
|
||||
import { GenerationDecoder } from '@raptorqr/core/fec/rlnc_decoder';
|
||||
import { assemblePayload } from '@raptorqr/core/reconstruct/assemble';
|
||||
import { K, MAX_PAYLOAD_SIZE } from '@raptorqr/core/protocol/constants';
|
||||
import { inflateSync } from 'fflate';
|
||||
|
||||
describe('Outer EC Benefit', () => {
|
||||
+4
-4
@@ -1,13 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CRC32C_SIZE, HEADER_SIZE, RAPTORQ_SYMBOL_INDEX } from '@/core/protocol/constants';
|
||||
import { createPacket, packetCodec, parsePacket } from '@/core/protocol/packet';
|
||||
import { CRC32C_SIZE, HEADER_SIZE, RAPTORQ_SYMBOL_INDEX } from '@raptorqr/core/protocol/constants';
|
||||
import { createPacket, packetCodec, parsePacket } from '@raptorqr/core/protocol/packet';
|
||||
import {
|
||||
RaptorQWasmDecoder,
|
||||
encodeRaptorQPackets,
|
||||
ensureRaptorQWasm,
|
||||
raptorQUnavailableMessage,
|
||||
} from '@/core/fec/raptorq_wasm';
|
||||
import { packetizeRaptorQ } from '@/core/sender/raptorq_packetizer';
|
||||
} from '@raptorqr/core/fec/raptorq_wasm';
|
||||
import { packetizeRaptorQ } from '@raptorqr/core/sender/raptorq_packetizer';
|
||||
|
||||
describe('RaptorQ codec sentinel', () => {
|
||||
it('should classify existing RLNC packets and RaptorQ sentinel packets', () => {
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, parse, resolve } from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export interface NodeWasmAsset {
|
||||
@@ -16,10 +17,14 @@ export interface NodeWasmAsset {
|
||||
distFileName: string;
|
||||
/** Repo/package-relative source path kept for dev and npm source fallback. */
|
||||
sourceRelativePath: string;
|
||||
/** Optional package export path, e.g. `@raptorqr/wasm/fast-qr/wasm/file.wasm`. */
|
||||
packageExport?: string;
|
||||
/** Optional absolute path override for local debugging. */
|
||||
envVar?: string;
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export function readNodeWasmAsset(asset: NodeWasmAsset): Uint8Array {
|
||||
for (const candidate of nodeWasmAssetCandidates(asset)) {
|
||||
if (existsSync(candidate)) {
|
||||
@@ -39,6 +44,7 @@ export function nodeWasmAssetCandidates(asset: NodeWasmAsset): string[] {
|
||||
asset.envVar ? process.env[asset.envVar] : undefined,
|
||||
resolve(here, asset.distFileName),
|
||||
resolve(process.cwd(), 'dist', asset.distFileName),
|
||||
resolvePackageAsset(asset.packageExport),
|
||||
resolve(process.cwd(), asset.sourceRelativePath),
|
||||
findUp(here, asset.sourceRelativePath),
|
||||
];
|
||||
@@ -46,6 +52,15 @@ export function nodeWasmAssetCandidates(asset: NodeWasmAsset): string[] {
|
||||
return dedupe(candidates.filter((value): value is string => Boolean(value)));
|
||||
}
|
||||
|
||||
function resolvePackageAsset(packageExport: string | undefined): string | undefined {
|
||||
if (!packageExport) return undefined;
|
||||
try {
|
||||
return require.resolve(packageExport);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function findUp(startDir: string, relativePath: string): string | undefined {
|
||||
let dir = resolve(startDir);
|
||||
const root = parse(dir).root;
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['src/tests/**/*.test.ts'],
|
||||
setupFiles: ['../../apps/web/src/tests/setup.ts'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@raptorqr/wasm",
|
||||
"version": "0.1.0",
|
||||
"description": "Generated WASM artifacts and build scripts for RaptorQR",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./fast-qr": {
|
||||
"types": "./src/fast_qr/index.ts",
|
||||
"import": "./src/fast_qr/index.ts"
|
||||
},
|
||||
"./raptorq": {
|
||||
"types": "./src/raptorq/index.ts",
|
||||
"import": "./src/raptorq/index.ts"
|
||||
},
|
||||
"./fast-qr/wasm/*": "./src/fast_qr/wasm/*",
|
||||
"./raptorq/wasm/*": "./src/raptorq/wasm/*"
|
||||
},
|
||||
"files": [
|
||||
"src/fast_qr/",
|
||||
"src/raptorq/",
|
||||
"scripts/"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "pnpm run verify:raptorq",
|
||||
"verify:raptorq": "node scripts/verify-raptorq-wasm.mjs"
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build erwanvivien/fast_qr as a wasm-bindgen module for QR Stream.
|
||||
"""Build erwanvivien/fast_qr as a wasm-bindgen module for RaptorQR.
|
||||
|
||||
This script is intended for Google Colab so the main development machine does
|
||||
not need a Rust toolchain. It can be pasted directly into a Colab cell. If a
|
||||
QR Stream repo is present, artifacts are copied into src/fast_qr_wasm/wasm;
|
||||
RaptorQR repo is present, artifacts are copied into packages/raptorqr-wasm/src/fast_qr/wasm;
|
||||
otherwise they are written to /content/qrstream_fast_qr_wasm_artifacts and zipped.
|
||||
|
||||
The crate exposes a single `QrRenderer` struct whose internal RGBA and matrix
|
||||
@@ -349,19 +349,19 @@ def find_repo_root() -> Path | None:
|
||||
resolved_script = Path(script_path).resolve()
|
||||
candidates.extend(resolved_script.parents)
|
||||
|
||||
env_repo = os.environ.get("QRSTREAM_REPO")
|
||||
env_repo = os.environ.get("RAPTORQR_REPO") or os.environ.get("QRSTREAM_REPO")
|
||||
if env_repo:
|
||||
candidates.append(Path(env_repo).expanduser())
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate and (candidate / "src" / "fast_qr_wasm").exists() and (candidate / "package.json").exists():
|
||||
if candidate and (candidate / "packages" / "raptorqr-wasm" / "src" / "fast_qr").exists() and (candidate / "pnpm-workspace.yaml").exists():
|
||||
return candidate.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def default_output_dir(repo_root: Path | None) -> Path:
|
||||
if repo_root is not None:
|
||||
return repo_root / "src" / "fast_qr_wasm" / "wasm"
|
||||
return repo_root / "packages" / "raptorqr-wasm" / "src" / "fast_qr" / "wasm"
|
||||
return Path("/content/qrstream_fast_qr_wasm_artifacts")
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
default,
|
||||
initSync,
|
||||
QrRenderer,
|
||||
} from './wasm/qrstream_fast_qr_wasm.js';
|
||||
export type { InitOutput } from './wasm/qrstream_fast_qr_wasm.js';
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build cberner/raptorq as a wasm-bindgen module for QR Stream.
|
||||
"""Build cberner/raptorq as a wasm-bindgen module for RaptorQR.
|
||||
|
||||
This script is intended for Google Colab so the main development machine does
|
||||
not need a Rust toolchain. It can be pasted directly into a Colab cell. If a
|
||||
QR Stream repo is present, artifacts are copied into src/raptorq/wasm; otherwise
|
||||
RaptorQR repo is present, artifacts are copied into packages/raptorqr-wasm/src/raptorq/wasm; otherwise
|
||||
they are written to /content/qrstream_raptorq_wasm_artifacts and zipped.
|
||||
"""
|
||||
|
||||
@@ -187,19 +187,19 @@ def find_repo_root() -> Path | None:
|
||||
resolved_script = Path(script_path).resolve()
|
||||
candidates.extend(resolved_script.parents)
|
||||
|
||||
env_repo = os.environ.get("QRSTREAM_REPO")
|
||||
env_repo = os.environ.get("RAPTORQR_REPO") or os.environ.get("QRSTREAM_REPO")
|
||||
if env_repo:
|
||||
candidates.append(Path(env_repo).expanduser())
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate and (candidate / "src" / "raptorq").exists() and (candidate / "package.json").exists():
|
||||
if candidate and (candidate / "packages" / "raptorqr-wasm" / "src" / "raptorq").exists() and (candidate / "pnpm-workspace.yaml").exists():
|
||||
return candidate.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def default_output_dir(repo_root: Path | None) -> Path:
|
||||
if repo_root is not None:
|
||||
return repo_root / "src" / "raptorq" / "wasm"
|
||||
return repo_root / "packages" / "raptorqr-wasm" / "src" / "raptorq" / "wasm"
|
||||
return Path("/content/qrstream_raptorq_wasm_artifacts")
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
default,
|
||||
initSync,
|
||||
RaptorQDecoder,
|
||||
encode_packets,
|
||||
} from './wasm/qrstream_raptorq_wasm.js';
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
Generated
+45
-13
@@ -7,19 +7,6 @@ settings:
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
fflate:
|
||||
specifier: ^0.8.2
|
||||
version: 0.8.3
|
||||
gifenc:
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3
|
||||
preact:
|
||||
specifier: 10.29.4
|
||||
version: 10.29.4
|
||||
zxing-wasm:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0(@types/emscripten@1.41.5)
|
||||
devDependencies:
|
||||
'@preact/preset-vite':
|
||||
specifier: '2'
|
||||
@@ -43,6 +30,51 @@ importers:
|
||||
specifier: '3'
|
||||
version: 3.2.7(@types/node@26.1.0)(happy-dom@20.10.6)
|
||||
|
||||
apps/web:
|
||||
dependencies:
|
||||
'@raptorqr/core':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/raptorqr-core
|
||||
'@raptorqr/wasm':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/raptorqr-wasm
|
||||
fflate:
|
||||
specifier: ^0.8.2
|
||||
version: 0.8.3
|
||||
preact:
|
||||
specifier: 10.29.4
|
||||
version: 10.29.4
|
||||
devDependencies:
|
||||
'@preact/preset-vite':
|
||||
specifier: '2'
|
||||
version: 2.10.5(@babel/core@7.29.7)(preact@10.29.4)(rollup@4.62.2)(vite@6.4.3(@types/node@26.1.0))
|
||||
|
||||
packages/raptorqr-cli:
|
||||
dependencies:
|
||||
'@raptorqr/core':
|
||||
specifier: workspace:*
|
||||
version: link:../raptorqr-core
|
||||
'@raptorqr/wasm':
|
||||
specifier: workspace:*
|
||||
version: link:../raptorqr-wasm
|
||||
|
||||
packages/raptorqr-core:
|
||||
dependencies:
|
||||
'@raptorqr/wasm':
|
||||
specifier: workspace:*
|
||||
version: link:../raptorqr-wasm
|
||||
fflate:
|
||||
specifier: ^0.8.2
|
||||
version: 0.8.3
|
||||
gifenc:
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3
|
||||
zxing-wasm:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0(@types/emscripten@1.41.5)
|
||||
|
||||
packages/raptorqr-wasm: {}
|
||||
|
||||
packages:
|
||||
|
||||
'@babel/code-frame@7.29.7':
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
- "apps/*"
|
||||
@@ -1,19 +0,0 @@
|
||||
import { copyFileSync, mkdirSync } from 'node:fs';
|
||||
import { basename, join } from 'node:path';
|
||||
|
||||
const root = process.cwd();
|
||||
const dist = join(root, 'dist');
|
||||
|
||||
const assets = [
|
||||
{
|
||||
source: join(root, 'src', 'fast_qr_wasm', 'wasm', 'qrstream_fast_qr_wasm_bg.wasm'),
|
||||
target: join(dist, 'qrstream_fast_qr_wasm_bg.wasm'),
|
||||
},
|
||||
];
|
||||
|
||||
mkdirSync(dist, { recursive: true });
|
||||
|
||||
for (const asset of assets) {
|
||||
copyFileSync(asset.source, asset.target);
|
||||
console.log(`copied ${basename(asset.target)}`);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>QR-over-GIF Transfer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
+2
-22
@@ -1,24 +1,4 @@
|
||||
{
|
||||
"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"]
|
||||
"extends": "./tsconfig.base.json",
|
||||
"include": []
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user