migrate to RaptorQR monorepo

This commit is contained in:
infrost
2026-07-08 12:50:16 +01:00
parent bea0f5cf03
commit 82880694bc
96 changed files with 799 additions and 942 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# dependencies (bun install)
# dependencies
node_modules
# output
+65 -356
View File
@@ -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 140, 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 011 : generation index (04095)
├─ bits 1223 : total generations (04095)
├─ bits 2428 : symbol index (031)
├─ bit 29 : isText flag (1 = text, 0 = file)
├─ bit 30 : isLastGeneration flag
└─ bit 31 : compressed flag
5 3 Data length (preprocessed size, 24-bit, 016,777,215)
8 201 Payload (zero-padded to 201 B)
209 4 CRC32-C over bytes 0208
```
Total: **213 bytes** → fits in a V10-M QR code.
**Symbol index convention:**
- `015` = systematic symbol (`sourceIndex = symbolIndex`)
- `1623` = coded repair symbol (`codedSymbolIndex = symbolIndex 16`)
- `2431` = 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 (0255)]
[N bytes: filename UTF-8]
[1 byte: MIME type length (0255)]
[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 140, 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
+57 -191
View File
@@ -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).
+1 -1
View File
@@ -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>
+23
View File
@@ -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';
+11
View File
@@ -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 -1
View File
@@ -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: [
-1
View File
@@ -1 +0,0 @@
console.log("Hello via Bun!");
+10 -42
View File
@@ -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"
}
}
+24
View File
@@ -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;
@@ -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);
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*.ts"]
}
+9
View File
@@ -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'],
},
});
+63
View File
@@ -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"
}
}
+7
View File
@@ -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> {
+14
View File
@@ -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';
+2
View File
@@ -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,
@@ -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 ───────────────────────────────────────────────────────────────────
@@ -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(
@@ -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', () => {
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
+9
View File
@@ -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'],
},
});
+28
View File
@@ -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"
}
}
@@ -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';
@@ -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';
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
+45 -13
View File
@@ -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':
+3
View File
@@ -0,0 +1,3 @@
packages:
- "packages/*"
- "apps/*"
-19
View File
@@ -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)}`);
}
-12
View File
@@ -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>
+18
View File
@@ -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
View File
@@ -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": []
}