Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions docs/superpowers/specs/2026-08-01-optical-transfer-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Optical File Transfer ("QR Beam") — Design

**Date:** 2026-08-01
**Tool:** Network → Optical File Transfer (`/tools/optical-transfer`) — NEW
**Type:** New tool
**Icon:** `ScanLine`
**Category:** Network

Inspired by Decimen Optical Transfer (MIT). Transfer a file between two devices using **only
a screen and a camera** — no network at all. The sender shows an animated stream of QR codes;
the receiver's camera reads them and reconstructs the file. Even more "no server" than the
WebRTC P2P transfer — works air-gapped.

## Core idea — LT fountain coding

The camera will miss/blur frames, so we can't send "block 1, block 2, …". Instead, each QR
frame is an **XOR of a pseudo-random subset of file blocks**, with the subset derived
deterministically from the frame's **sequence number** (both sides share the PRNG). The
receiver collects frames in any order until it has ~K×1.15 of them and solves the file by
peeling (belief propagation). No pairing, no ACKs, no retransmission — the sender just loops.

## Files

- `src/tools/optical/fountain.lib.ts` (pure, TDD) — the LT codec:
- `mulberry32(seed)` deterministic PRNG.
- `robustSoliton(K)` degree distribution + `sampleDegree(rng, dist)`.
- `frameIndices(seq, K)` → deterministic block indices for a frame (seed rng with seq).
- `bytesToBlocks(bytes, blockSize)` / `blocksToBytes(blocks, size)`.
- `LtEncoder(blocks)` → `frame(seq): Uint8Array` (XOR of `frameIndices` blocks).
- `LtDecoder(k, blockSize)` → `addFrame(seq, payload): boolean` (done?), `progress()`, `recover(): Uint8Array`.
Peeling decoder: reduce each equation by solved blocks; when a frame reaches degree 1,
solve + cascade.
- `src/tools/optical/frame.lib.ts` (pure, TDD) — wire format for one QR frame:
- `encodeFrame({ session, k, size, seq, payload })` → bytes (magic, version, session u16,
k u24, size u32, seq u32, payload). `decodeFrame(bytes)` → parsed | null (validates magic).
- `fnv1a(bytes)` small hash for a session id + integrity.
- `src/tools/optical/qr.lib.ts` — QR render (qrcode, byte-mode segment) + jsqr decode wrappers
(browser; build + manual smoke).
- `src/hooks/useOpticalReceive.ts` — camera loop: grab frame → jsqr → decodeFrame → decoder →
progress → complete. Reuses camera handling.
- `src/islands/network/OpticalTransfer.tsx` — role picker; Send (Dropzone → animated QR,
looping seq via rAF at a target fps) / Receive (camera + progress + download).
- `src/registry/tools.ts` — register `optical-transfer` (Network, `ScanLine`, beta).

## Parameters

- **Block size** ~256 bytes (payload fits a mid-density QR that scans reliably from a phone).
- **QR:** byte-mode segment, ECC level **L** (max capacity; fountain coding already handles loss).
- **Frame rate:** target ~10 fps sender (configurable); receiver decodes as fast as it can.
- **Throughput (v1, jsqr):** modest (~KB/s) — great for text, keys, configs, small images. A
future zxing-wasm + Worker upgrade would raise it (that's what Decimen uses for ~129 KB/s).
A soft size cap warns for large files.

## Decoding correctness

Robust soliton produces enough degree-1 frames to bootstrap peeling and keeps overhead near
~10-15%. `frameIndices` MUST be identical on both sides (deterministic PRNG seeded by seq
only). The frame carries `k`/`size`/`session` so the receiver is fully autonomous from any
frame. A session hash guards against mixing frames from a different transfer.

## Testing (Vitest — the whole codec is headless-testable)

- `fountain.lib.test.ts` — PRNG determinism; `frameIndices` deterministic + within range +
degree matches; **round-trip**: random bytes → blocks → generate ~K×1.3 frames (in random
order, drop some) → decode → recovers original exactly, for several sizes. Degenerate K=1.
- `frame.lib.test.ts` — encode/decode round-trip; rejects bad magic/truncated; `fnv1a` stable.
- QR/camera/island: build + manual smoke (two devices; beam a small file).

## Out of scope (v1)

- zxing-wasm / Worker decode (throughput upgrade — follow-up).
- Multi-file, folders, resume.
- Big files (soft-capped; it's an optical channel).
212 changes: 212 additions & 0 deletions src/islands/network/OpticalTransfer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { useEffect, useRef, useState } from 'react';
import { Upload, Camera, RefreshCw } from 'lucide-react';
import { Dropzone } from '@/components/ui/Dropzone';
import { Button } from '@/components/ui/Button';
import { Alert } from '@/components/ui/Alert';
import { ProgressBar } from '@/components/ui/ProgressBar';
import { downloadService } from '@/services/download';
import { useCamera } from '@/hooks/useCamera';
import { renderQr, decodeQr } from '@/tools/optical/qr.lib';
import { encodeFrame, decodeFrame, fnv1a, packFile, unpackFile } from '@/tools/optical/frame.lib';
import { bytesToBlocks, blocksToBytes, LtEncoder, LtDecoder } from '@/tools/optical/fountain.lib';

type Role = 'send' | 'receive' | null;

const BLOCK_SIZE = 200; // payload bytes per frame (frame ≈ 218 B → a phone-scannable QR)
const SEND_FPS = 8;
const BIG_FILE = 256 * 1024; // warn beyond this — the optical channel is slow
const CAPTURE_W = 720; // downscale camera frames for faster decoding

function randU16(): number {
return Math.floor((crypto.getRandomValues(new Uint16Array(1))[0]));
}

export default function OpticalTransfer() {
const [role, setRole] = useState<Role>(null);

return (
<div className="space-y-4">
{role === null && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Transfer a file between two devices with <strong>just a screen and a camera</strong> — no network, no
accounts, nothing sent to any server. One device shows animated QR codes; the other reads them.
</p>
<div className="flex flex-wrap gap-2">
<Button onClick={() => setRole('send')}><Upload className="h-4 w-4" /> Send a file</Button>
<Button variant="secondary" onClick={() => setRole('receive')}><Camera className="h-4 w-4" /> Receive a file</Button>
</div>
</div>
)}

{role === 'send' && <Sender onBack={() => setRole(null)} />}
{role === 'receive' && <Receiver onBack={() => setRole(null)} />}
</div>
);
}

function Sender({ onBack }: { onBack: () => void }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const encoderRef = useRef<LtEncoder | null>(null);
const metaRef = useRef<{ session: number; k: number; size: number; hash: number } | null>(null);
const seqRef = useRef(0);
const rafRef = useRef<number | null>(null);
const lastRef = useRef(0);

const [info, setInfo] = useState<{ name: string; size: number; k: number } | null>(null);
const [big, setBig] = useState(false);

const stop = () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); rafRef.current = null; };
useEffect(() => () => stop(), []);

const onDrop = async (files: File[]) => {
const file = files[0];
if (!file) return;
stop();
const data = new Uint8Array(await file.arrayBuffer());
const container = packFile(file.name, data);
const blocks = bytesToBlocks(container, BLOCK_SIZE);
encoderRef.current = new LtEncoder(blocks);
metaRef.current = { session: randU16(), k: blocks.length, size: container.length, hash: fnv1a(container) };
seqRef.current = 0;
setInfo({ name: file.name, size: file.size, k: blocks.length });
setBig(container.length > BIG_FILE);

const tick = (t: number) => {
if (t - lastRef.current >= 1000 / SEND_FPS && canvasRef.current && encoderRef.current && metaRef.current) {
lastRef.current = t;
const seq = seqRef.current++;
const payload = encoderRef.current.frame(seq);
renderQr(canvasRef.current, encodeFrame({ ...metaRef.current, seq, payload }));
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
};

return (
<div className="space-y-4">
<Button variant="ghost" onClick={() => { stop(); onBack(); }}>← Back</Button>
{!info && (
<Dropzone onDrop={onDrop} multiple={false}>
<div className="space-y-1">
<p className="text-lg font-bold">Drop a file to beam</p>
<p className="text-sm text-muted-foreground">Best for small files (text, keys, docs, small images) · stays on your device</p>
</div>
</Dropzone>
)}
{info && (
<div className="space-y-3">
<p className="text-sm font-bold">{info.name} · {info.k} blocks</p>
{big && <p className="border-2 border-border bg-muted px-3 py-2 text-sm">⚠️ This file is on the large side for an optical transfer — it may take several minutes. Keep both devices steady.</p>}
<div className="flex justify-center border-2 border-border bg-white p-2">
<canvas ref={canvasRef} className="h-auto w-full max-w-md" style={{ imageRendering: 'pixelated' }} />
</div>
<p className="text-center text-sm text-muted-foreground">Point the other device&apos;s camera at this code. It loops until the file is received.</p>
<Button variant="secondary" onClick={() => { stop(); setInfo(null); }}>Choose another file</Button>
</div>
)}
</div>
);
}

function Receiver({ onBack }: { onBack: () => void }) {
const { videoRef, stream, error, start, stop } = useCamera();
const captureRef = useRef<HTMLCanvasElement | null>(null);
const decoderRef = useRef<LtDecoder | null>(null);
const metaRef = useRef<{ session: number; k: number; size: number; hash: number } | null>(null);
const rafRef = useRef<number | null>(null);

const [progress, setProgress] = useState(0);
const [frames, setFrames] = useState(0);
const [result, setResult] = useState<{ blob: Blob; name: string } | null>(null);
const [failed, setFailed] = useState('');

useEffect(() => { start(); return () => { stop(); if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); }; }, [start, stop]);
useEffect(() => { if (stream && videoRef.current) videoRef.current.play().catch(() => {}); }, [stream, videoRef]);

useEffect(() => {
if (!stream) return;
if (!captureRef.current) captureRef.current = document.createElement('canvas');
let collected = 0;

const scan = () => {
const video = videoRef.current;
const canvas = captureRef.current;
if (video && canvas && video.videoWidth > 0 && !result) {
const scale = Math.min(1, CAPTURE_W / video.videoWidth);
const w = Math.round(video.videoWidth * scale);
const h = Math.round(video.videoHeight * scale);
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (ctx) {
ctx.drawImage(video, 0, 0, w, h);
const bytes = decodeQr(ctx.getImageData(0, 0, w, h));
const frame = bytes && decodeFrame(bytes);
if (frame) {
if (!metaRef.current) {
metaRef.current = { session: frame.session, k: frame.k, size: frame.size, hash: frame.hash };
decoderRef.current = new LtDecoder(frame.k, BLOCK_SIZE);
}
if (frame.session === metaRef.current.session && decoderRef.current) {
const before = decoderRef.current.solvedCount;
const done = decoderRef.current.addFrame(frame.seq, frame.payload);
if (decoderRef.current.solvedCount !== before || decoderRef.current.progress() > 0) {
collected++;
setFrames(collected);
setProgress(decoderRef.current.progress());
}
if (done) finish();
}
}
}
}
rafRef.current = requestAnimationFrame(scan);
};

const finish = () => {
const meta = metaRef.current!;
const container = blocksToBytes(decoderRef.current!.recover(), meta.size);
if (fnv1a(container) !== meta.hash) { setFailed('Received the file but its checksum didn’t match — try again.'); return; }
const { name, data } = unpackFile(container);
setResult({ blob: new Blob([data]), name: name || 'received-file' });
stop();
};

rafRef.current = requestAnimationFrame(scan);
return () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); };
}, [stream, videoRef, stop, result]);

const download = () => { if (result) downloadService.download(result.blob, result.name); };

return (
<div className="space-y-4">
<Button variant="ghost" onClick={() => { stop(); onBack(); }}>← Back</Button>
{error && <Alert variant="error">{error.message}</Alert>}
{failed && <Alert variant="error">{failed}</Alert>}

{!result && (
<>
<video ref={videoRef} playsInline muted className="max-h-96 w-full border-2 border-border bg-black object-contain" />
<p className="text-sm text-muted-foreground">Point your camera at the other device&apos;s animated QR code and hold steady.</p>
{metaRef.current && (
<div className="space-y-1">
<ProgressBar percent={progress * 100} label={`Receiving — ${metaRef.current.k} blocks`} />
<p className="text-xs text-muted-foreground">{frames} frames captured</p>
</div>
)}
</>
)}

{result && (
<div className="space-y-2 border-2 border-border p-3">
<Alert variant="success">Received <strong>{result.name}</strong> ({result.blob.size.toLocaleString()} bytes).</Alert>
<div className="flex flex-wrap gap-2">
<Button onClick={download}>Download file</Button>
<Button variant="secondary" onClick={() => location.reload()}><RefreshCw className="h-4 w-4" /> Receive another</Button>
</div>
</div>
)}
</div>
);
}
11 changes: 11 additions & 0 deletions src/registry/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,17 @@ export const tools: ToolDef[] = [
load: () => import('@/islands/network/VideoCall'),
status: 'beta'
},
{
id: 'optical-transfer',
name: 'Optical File Transfer',
category: 'Network',
route: '/tools/optical-transfer',
keywords: ['optical', 'qr', 'transfer', 'beam', 'camera', 'screen', 'offline', 'air-gap', 'no network', 'fountain', 'file'],
icon: ScanLine,
summary: 'Beam a file device-to-device with QR codes — no network at all',
load: () => import('@/islands/network/OpticalTransfer'),
status: 'beta'
},
{
id: 'file-crypt',
name: 'File Encrypt / Decrypt',
Expand Down
Loading
Loading