refactor: replace manifest with fixed header, single V10-M profile

- Remove CBOR manifest and manifest.ts entirely
- Fixed 18-byte packet header: sessionId, generationIndex, symbolIndex,
  packetType, totalGenerations, dataLength, flags, reserved
- Single hardcoded profile: QR V10, ECC M, K=16, R=8, payload=191 bytes
- Add isText flag (bit 0 of flags) for text vs binary routing
- Remove hash computation and profile selection UI
- Update all tests to match new protocol (33 tests passing)
- Fix generateCoefficients arg order bug in complete.test.ts
- Fix createQRGif dimension bug in tests
- Make prod_roundtrip frame loss deterministic

All 33 tests pass. Build succeeds.
This commit is contained in:
Hermes Agent
2026-05-03 12:10:09 +00:00
parent 2506ac317c
commit bbe49ddee8
15 changed files with 1101 additions and 2512 deletions
+73 -50
View File
@@ -1,5 +1,6 @@
/**
* Receiver page — camera preview, QR decode, GIF file upload mode, file download.
* Receiver page — camera preview, QR decode, GIF file upload mode,
* file download, and text display.
*/
import { useState, useCallback, useRef, useEffect } from 'preact/hooks';
import { parseGif, renderGifFrame } from '@/core/gif/gif_parser';
@@ -23,8 +24,6 @@ interface ReceivedFile {
type InputMode = 'camera' | 'gif-file';
// ─── Styles ──────────────────────────────────────────────────────────────────
type CSSProps = Record<string, string | number>;
const S = {
@@ -70,15 +69,6 @@ const S = {
fontWeight: 600,
cursor: 'pointer',
} as CSSProps,
btnSecondary: {
background: '#21262d',
color: '#c9d1d9',
border: '1px solid #30363d',
borderRadius: 6,
padding: '10px 24px',
fontSize: 15,
cursor: 'pointer',
} as CSSProps,
video: {
width: '100%',
maxWidth: 480,
@@ -167,9 +157,22 @@ const S = {
color: active ? '#f0f6fc' : '#8b949e',
transition: 'all 0.15s',
}),
textarea: {
width: '100%',
boxSizing: 'border-box' as const,
background: '#0d1117',
color: '#c9d1d9',
border: '1px solid #30363d',
borderRadius: 6,
padding: 12,
fontSize: 14,
fontFamily: 'monospace',
resize: 'vertical' as const,
minHeight: 120,
} as CSSProps,
};
// ─── Component ───────────────────────────────────────────────────────────────
// ─── Component ───────────────────────────────────────────────────────────────────
export function ReceiverPage() {
const videoRef = useRef<HTMLVideoElement>(null);
@@ -188,9 +191,10 @@ export function ReceiverPage() {
const [totalGens, setTotalGens] = useState(0);
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [receivedFile, setReceivedFile] = useState<ReceivedFile | null>(null);
const [receivedText, setReceivedText] = useState('');
const [error, setError] = useState('');
// ── Create decode worker ──────────────────────────────────────────────────
// ── Create decode worker ───────────────────────────────────────────────
function createWorker(): Worker {
const w = new Worker(
new URL('@/workers/decode.worker.ts', import.meta.url),
@@ -207,9 +211,9 @@ export function ReceiverPage() {
setProgress(msg.totalGenerations > 0 ? msg.solvedGenerations / msg.totalGenerations : 0);
setStatus(msg.status);
if (msg.sessionId) {
if (msg.sessionId !== undefined) {
const sid = String(msg.sessionId);
setSessions((prev) => {
const sid = msg.sessionId as string;
const existing = prev.find((s) => s.sessionId === sid);
if (existing) {
return prev.map((s) =>
@@ -241,16 +245,22 @@ export function ReceiverPage() {
break;
}
case 'complete': {
setReceivedFile({
data: msg.data as ArrayBuffer,
filename: msg.filename ?? 'recovered',
mime: msg.mime ?? 'application/octet-stream',
});
if (msg.isText) {
setReceivedText(msg.text);
setReceivedFile(null);
} else {
setReceivedFile({
data: msg.data as ArrayBuffer,
filename: msg.filename ?? 'recovered',
mime: msg.mime ?? 'application/octet-stream',
});
setReceivedText('');
}
setStatus('Complete ✓');
setProgress(1);
setSessions((prev) =>
prev.map((s) =>
s.sessionId === msg.sessionId ? { ...s, status: 'complete' as const, progress: 1 } : s,
s.sessionId === String(msg.sessionId) ? { ...s, status: 'complete' as const, progress: 1 } : s,
),
);
break;
@@ -259,7 +269,7 @@ export function ReceiverPage() {
setError(msg.message);
setSessions((prev) =>
prev.map((s) =>
s.sessionId === msg.sessionId ? { ...s, status: 'error' as const } : s,
s.sessionId === String(msg.sessionId) ? { ...s, status: 'error' as const } : s,
),
);
break;
@@ -274,10 +284,11 @@ export function ReceiverPage() {
return w;
}
// ── Start camera scanning ─────────────────────────────────────────────────
// ── Start camera scanning ────────────────────────────────────────────
const startCameraScanning = useCallback(async () => {
setError('');
setReceivedFile(null);
setReceivedText('');
setSessions([]);
setProgress(0);
setFramesDecoded(0);
@@ -286,7 +297,7 @@ export function ReceiverPage() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 640 }, height: { ideal: 640 } },
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 1280 } },
audio: false,
});
streamRef.current = stream;
@@ -302,7 +313,6 @@ export function ReceiverPage() {
scanningRef.current = true;
setStatus('Scanning…');
// rAF capture loop
let lastCapture = 0;
const CAPTURE_INTERVAL = 150;
const loop = (time: number) => {
@@ -319,7 +329,7 @@ export function ReceiverPage() {
}
}, []);
// ── Process GIF file ──────────────────────────────────────────────────────
// ── Process GIF file ───────────────────────────────────────────────────
const handleGifFile = useCallback(async (e: Event) => {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
@@ -327,6 +337,7 @@ export function ReceiverPage() {
setError('');
setReceivedFile(null);
setReceivedText('');
setSessions([]);
setProgress(0);
setFramesDecoded(0);
@@ -355,8 +366,6 @@ export function ReceiverPage() {
for (let i = 0; i < gifData.frames.length; i++) {
if (!scanningRef.current) break;
const rgba = renderGifFrame(gifData, i);
// Send raw pixel buffer (ArrayBuffer) instead of ImageData to avoid
// structured clone issues with ImageData in some browsers.
const pixelBuf = rgba.buffer.slice(rgba.byteOffset, rgba.byteOffset + rgba.byteLength);
worker.postMessage(
{ type: 'frame', pixels: pixelBuf, width: gifData.width, height: gifData.height },
@@ -373,7 +382,7 @@ export function ReceiverPage() {
}
}, []);
// ── Stop scanning ────────────────────────────────────────────────────────
// ── Stop scanning ─────────────────────────────────────────────────────
const stopScanning = useCallback(() => {
setScanning(false);
scanningRef.current = false;
@@ -394,7 +403,7 @@ export function ReceiverPage() {
setStatus('Stopped');
}, []);
// ── Capture frame from camera ────────────────────────────────────────────
// ── Capture frame from camera (with 3× digital zoom crop) ────────────────
const captureFrame = useCallback(() => {
const video = videoRef.current;
const canvas = canvasRef.current;
@@ -412,16 +421,19 @@ export function ReceiverPage() {
const vw = video.videoWidth || 640;
const vh = video.videoHeight || 640;
const minDim = Math.min(vw, vh);
const sx = (vw - minDim) / 2;
const sy = (vh - minDim) / 2;
ctx.drawImage(video, sx, sy, minDim, minDim, 0, 0, cw, ch);
// 3× digital zoom: crop center 1/3 of the frame
const cropSize = minDim / 3;
const sx = (vw - cropSize) / 2;
const sy = (vh - cropSize) / 2;
ctx.drawImage(video, sx, sy, cropSize, cropSize, 0, 0, cw, ch);
const imageData = ctx.getImageData(0, 0, cw, ch);
worker.postMessage({ type: 'frame', imageData });
}, []);
// ── Download recovered file ─────────────────────────────────────────────
// ── Download recovered file ─────────────────────────────────────────────
const handleDownload = useCallback(() => {
if (!receivedFile) return;
const blob = new Blob([receivedFile.data], { type: receivedFile.mime });
@@ -435,7 +447,7 @@ export function ReceiverPage() {
URL.revokeObjectURL(url);
}, [receivedFile]);
// ── Cleanup on unmount ───────────────────────────────────────────────────
// ── Cleanup on unmount ───────────────────────────────────────────────
useEffect(() => {
return () => {
cancelAnimationFrame(animRef.current);
@@ -448,10 +460,10 @@ export function ReceiverPage() {
};
}, []);
// ── Render ───────────────────────────────────────────────────────────────
// ── Render ──────────────────────────────────────────────────────────────────
return (
<div>
{/* ── Input mode toggle ────────────────────────────────────────────── */}
{/* ── Input mode toggle ───────────────────────────────────────────────── */}
<div style={S.section}>
<div style={S.label}>Input Mode</div>
<div style={S.toggleGroup}>
@@ -470,7 +482,7 @@ export function ReceiverPage() {
</div>
</div>
{/* ── Camera preview ──────────────────────────────────────────────── */}
{/* ── Camera preview ────────────────────────────────────────────────── */}
{inputMode === 'camera' && (
<div style={S.section}>
<div style={S.label}>Camera</div>
@@ -487,23 +499,26 @@ export function ReceiverPage() {
</button>
)}
</div>
<p style={{ fontSize: 12, color: '#8b949e', marginTop: 6 }}>
3× digital zoom is applied automatically to the center of the frame.
</p>
{error && <div style={S.warn}> {error}</div>}
</div>
)}
{/* ── GIF file upload ──────────────────────────────────────────────── */}
{/* ── GIF file upload ──────────────────────────────────────────────── */}
{inputMode === 'gif-file' && (
<div style={S.section}>
<div style={S.label}>Upload GIF</div>
<p style={{ fontSize: 13, color: '#8b949e', marginBottom: 8 }}>
Upload a QR-over-GIF file generated by the Sender. The receiver will decode frames directly from the GIF.
Upload a QR-over-GIF file generated by the Sender.
</p>
<input type="file" accept=".gif,image/gif" onChange={handleGifFile} />
{error && <div style={{ ...S.warn, marginTop: 8 }}> {error}</div>}
</div>
)}
{/* ── Status + progress ────────────────────────────────────────────── */}
{/* ── Status + progress ────────────────────────────────────────────────── */}
<div style={S.section}>
<div style={S.label}>Status</div>
<div style={{ ...S.row, gap: 16 }}>
@@ -530,7 +545,7 @@ export function ReceiverPage() {
)}
</div>
{/* ── Sessions table ───────────────────────────────────────────────── */}
{/* ── Sessions table ─────────────────────────────────────────────────────── */}
{sessions.length > 0 && (
<div style={S.section}>
<div style={S.label}>Sessions</div>
@@ -546,11 +561,7 @@ export function ReceiverPage() {
<tbody>
{sessions.map((s) => (
<tr key={s.sessionId}>
<td style={S.td}>
{s.sessionId.length > 16
? `${s.sessionId.slice(0, 16)}`
: s.sessionId}
</td>
<td style={S.td}>{s.sessionId}</td>
<td style={S.td}>
<div
style={{
@@ -580,7 +591,19 @@ export function ReceiverPage() {
</div>
)}
{/* ── Download recovered file ──────────────────────────────────────── */}
{/* ── Received text ───────────────────────────────────────────────────── */}
{receivedText && (
<div style={S.section}>
<div style={S.label}>Recovered Text</div>
<textarea
style={S.textarea}
value={receivedText}
readOnly
/>
</div>
)}
{/* ── Download recovered file ───────────────────────────────────────────── */}
{receivedFile && (
<div style={S.section}>
<div style={S.label}>Recovered File</div>
@@ -598,7 +621,7 @@ export function ReceiverPage() {
);
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
// ─── Helpers ───────────────────────────────────────────────────────────────────
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
+33 -111
View File
@@ -1,23 +1,11 @@
/**
* Sender page — text/file input, profile selection, GIF generation preview.
* Sender page — text/file input, GIF generation preview.
*/
import { useState, useCallback } from 'preact/hooks';
import { ProfileId, PROFILES } from '@/core/protocol/constants';
import type { ManifestData } from '@/core/protocol/manifest';
// ─── Types ───────────────────────────────────────────────────────────────────
type InputMode = 'text' | 'file';
type CompMode = 'auto' | 'off';
interface EncodeStats {
originalSize: number;
preprocessedSize: number;
frameCount: number;
estimatedGifBytes: number;
totalGenerations: number;
packetsPerGen: number;
}
interface GifResult {
gifData: ArrayBuffer;
@@ -26,7 +14,7 @@ interface GifResult {
frameCount: number;
}
// ─── Inline styles ───────────────────────────────────────────────────────────
// ─── Styles ───────────────────────────────────────────────────────────────────
type CSSProps = Record<string, string | number>;
@@ -53,14 +41,6 @@ const S = {
alignItems: 'center',
flexWrap: 'wrap' as const,
},
select: {
background: '#0d1117',
color: '#c9d1d9',
border: '1px solid #30363d',
borderRadius: 6,
padding: '8px 12px',
fontSize: 14,
},
btn: {
background: '#238636',
color: '#fff',
@@ -71,16 +51,6 @@ const S = {
fontWeight: 600,
cursor: 'pointer',
} as CSSProps,
btnDanger: {
background: '#da3633',
color: '#fff',
border: 'none',
borderRadius: 6,
padding: '10px 24px',
fontSize: 15,
fontWeight: 600,
cursor: 'pointer',
} as CSSProps,
btnSecondary: {
background: '#21262d',
color: '#c9d1d9',
@@ -159,24 +129,16 @@ const S = {
} as CSSProps,
};
const PROFILE_OPTIONS: { id: ProfileId; label: string }[] = [
{ id: ProfileId.ROBUST, label: 'Robust' },
{ id: ProfileId.BALANCED, label: 'Balanced' },
{ id: ProfileId.FAST, label: 'Fast' },
];
// ─── Component ───────────────────────────────────────────────────────────────
// ─── Component ───────────────────────────────────────────────────────────────────
export function SenderPage() {
const [mode, setMode] = useState<InputMode>('text');
const [text, setText] = useState('');
const [file, setFile] = useState<File | null>(null);
const [profileId, setProfileId] = useState<ProfileId>(ProfileId.ROBUST);
const [compression, setCompression] = useState<CompMode>('auto');
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState('');
const [gifResult, setGifResult] = useState<GifResult | null>(null);
const [stats, setStats] = useState<EncodeStats | null>(null);
const [stats, setStats] = useState<{ originalSize: number; preprocessedSize: number; frameCount: number; totalGenerations: number } | null>(null);
const [gifUrl, setGifUrl] = useState<string | null>(null);
const [error, setError] = useState('');
@@ -191,33 +153,28 @@ export function SenderPage() {
setStats(null);
if (gifUrl) { URL.revokeObjectURL(gifUrl); setGifUrl(null); }
// --- read input data ---
let data: ArrayBuffer;
let filename: string;
let mime: string;
let isText: boolean;
if (mode === 'text') {
const trimmed = text.trim();
if (!trimmed) { setError('Please enter some text.'); return; }
data = new TextEncoder().encode(trimmed).buffer;
filename = '';
mime = 'text/plain';
isText = true;
} else {
if (!file) { setError('Please select a file.'); return; }
if (file.size > 8 * 1024 * 1024) { setError('File too large. Maximum size is 8 MB.'); return; }
data = await file.arrayBuffer();
filename = file.name;
mime = file.type || 'application/octet-stream';
isText = false;
}
const profile = PROFILES[profileId];
const compress = compression === 'auto' ? data.byteLength > 512 : false;
const compress = data.byteLength > 64;
setBusy(true);
setStatus('Encoding data…');
try {
// ── Step 1: Encode worker ──────────────────────────────────────────
// ── Step 1: Encode worker ─────────────────────────────────────
const encodeWorker = new Worker(
new URL('@/workers/encode.worker.ts', import.meta.url),
{ type: 'module' },
@@ -225,8 +182,9 @@ export function SenderPage() {
const encoded = await new Promise<{
packets: Uint8Array[];
manifest: ManifestData;
stats: EncodeStats;
sessionId: number;
totalGenerations: number;
stats: { originalSize: number; preprocessedSize: number; frameCount: number };
}>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Encode worker timed out')), 120_000);
encodeWorker.onmessage = (e: MessageEvent) => {
@@ -239,16 +197,21 @@ export function SenderPage() {
};
encodeWorker.onerror = (err) => { clearTimeout(timeout); reject(err); };
encodeWorker.postMessage(
{ type: 'encode', data, profileId, filename, mime, compress },
{ type: 'encode', data, isText, compress },
[data],
);
});
encodeWorker.terminate();
setStats(encoded.stats);
setStats({
originalSize: encoded.stats.originalSize,
preprocessedSize: encoded.stats.preprocessedSize,
frameCount: encoded.stats.frameCount,
totalGenerations: encoded.totalGenerations,
});
setStatus(`Generating GIF (${encoded.stats.frameCount} frames)…`);
// ── Step 2: GIF worker ─────────────────────────────────────────────
// ── Step 2: GIF worker ─────────────────────────────────────────
const gifWorker = new Worker(
new URL('@/workers/gif.worker.ts', import.meta.url),
{ type: 'module' },
@@ -270,20 +233,20 @@ export function SenderPage() {
}
};
gifWorker.onerror = (err) => { clearTimeout(timeout); reject(err); };
// Transfer packets to avoid copy
const transfer: ArrayBufferLike[] = [];
const transferPackets = encoded.packets.map(p => {
if (p.buffer.byteLength <= 1024 * 1024) { transfer.push(p.buffer as ArrayBuffer); }
const transferPackets = encoded.packets.map((p) => {
if (p.buffer.byteLength <= 1024 * 1024) transfer.push(p.buffer as ArrayBuffer);
return p;
});
const transferList = transfer.length > 0 ? (transfer as ArrayBuffer[]) : [];
gifWorker.postMessage(
{ type: 'generate', packets: transferPackets, manifest: encoded.manifest, profile },
transfer.length > 0 ? (transfer as ArrayBuffer[]) : undefined,
{ type: 'generate', packets: transferPackets },
transferList,
);
});
gifWorker.terminate();
// ── Step 3: show result ─────────────────────────────────────────────
// ── Step 3: show result ────────────────────────────────────────
const url = URL.createObjectURL(new Blob([gif.gifData], { type: 'image/gif' }));
setGifUrl(url);
setGifResult(gif);
@@ -293,26 +256,22 @@ export function SenderPage() {
} finally {
setBusy(false);
}
}, [mode, text, file, profileId, compression, gifUrl]);
}, [mode, text, file, gifUrl]);
// ─── Download handler ──────────────────────────────────────────────────────
const handleDownload = useCallback(() => {
if (!gifResult) return;
const blob = new Blob([gifResult.gifData], { type: 'image/gif' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `qr-transfer-${profileId}-${stats?.totalGenerations ?? 0}g.gif`;
a.download = `qr-transfer-${stats?.totalGenerations ?? 0}g.gif`;
a.click();
URL.revokeObjectURL(url);
}, [gifResult, profileId, stats]);
// ─── Compute warnings ──────────────────────────────────────────────────────
const showWarning = false;
}, [gifResult, stats]);
return (
<div>
{/* ── Input mode toggle ───────────────────────────────────────────── */}
{/* ── Input mode toggle ───────────────────────────────────────────────── */}
<div style={S.section}>
<div style={S.row}>
<span style={S.label}>Input mode</span>
@@ -336,33 +295,6 @@ export function SenderPage() {
)}
</div>
{/* ── Profile + compression ───────────────────────────────────────── */}
<div style={S.section}>
<div style={S.row}>
<div>
<span style={S.label}>Profile</span>
<select
style={S.select}
value={profileId}
onChange={(e) => setProfileId(Number((e.target as HTMLSelectElement).value) as ProfileId)}
>
{PROFILE_OPTIONS.map((o) => (
<option key={o.id} value={o.id}>
{o.label} (V{PROFILES[o.id].qrVersion}-{PROFILES[o.id].eccLevel}, K={PROFILES[o.id].k})
</option>
))}
</select>
</div>
<div>
<span style={S.label}>Compression</span>
<div style={S.toggleGroup}>
<button style={S.toggleBtn(compression === 'auto')} onClick={() => setCompression('auto')}>Auto</button>
<button style={S.toggleBtn(compression === 'off')} onClick={() => setCompression('off')}>Off</button>
</div>
</div>
</div>
</div>
{/* ── Generate ────────────────────────────────────────────────────── */}
<div style={S.section}>
<button
@@ -382,7 +314,7 @@ export function SenderPage() {
{error && <div style={S.warn}> {error}</div>}
</div>
{/* ── Preview ─────────────────────────────────────────────────────── */}
{/* ── Preview ──────────────────────────────────────────────────────────── */}
{gifUrl && gifResult && (
<div style={S.section}>
<div style={S.label}>Preview</div>
@@ -395,7 +327,7 @@ export function SenderPage() {
</div>
)}
{/* ── Stats ───────────────────────────────────────────────────────── */}
{/* ── Stats ────────────────────────────────────────────────────────────── */}
{stats && (
<div style={S.section}>
<div style={S.label}>Transfer Info</div>
@@ -417,20 +349,10 @@ export function SenderPage() {
);
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
// ─── Helpers ───────────────────────────────────────────────────────────────────
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function estimateThroughput(originalSize: number, frameCount: number, profileId: ProfileId): string {
const profile = PROFILES[profileId];
const totalTimeSec = (frameCount * profile.frameDelay * 10) / 1000; // frameDelay is in centiseconds
if (totalTimeSec <= 0) return '—';
const bps = (originalSize * 8) / totalTimeSec;
if (bps < 1000) return `${bps.toFixed(0)} bps`;
if (bps < 1_000_000) return `${(bps / 1000).toFixed(1)} Kbps`;
return `${(bps / 1_000_000).toFixed(2)} Mbps`;
}
+43 -86
View File
@@ -1,6 +1,8 @@
/**
* Core protocol constants, enums, and profile definitions for the
* QR-over-GIF transfer system.
* Core protocol constants for the QR-over-GIF transfer system.
*
* Single hardcoded profile: V10, ECC M, K=16, R=8.
* No profile selection — sender and receiver are the same codebase.
*
* @module
*/
@@ -11,29 +13,33 @@
export const MAGIC_BYTES = new Uint8Array([0x51, 0x47]);
/** Current protocol version. */
export const PROTOCOL_VERSION = 1;
export const PROTOCOL_VERSION = 2;
// ─── Packet Geometry ─────────────────────────────────────────────────────────
/** Size of the fixed packet header in bytes (offsets 027). */
export const HEADER_SIZE = 28;
/** Size of the fixed packet header in bytes. */
export const HEADER_SIZE = 18;
/** Size of the CRC32C trailer in bytes. */
export const CRC32C_SIZE = 4;
/** Total overhead per packet: header + CRC32C (28 + 4 = 32). */
/** Total overhead per packet: header + CRC32C. */
export const PACKET_OVERHEAD = HEADER_SIZE + CRC32C_SIZE;
/** Max payload that fits in a V10-M QR code with our header. */
export const MAX_PAYLOAD_SIZE = 191;
/** Max total packet size that fits in a V10-M QR code. */
export const MAX_PACKET_SIZE = 213;
// ─── Packet Type Enum ────────────────────────────────────────────────────────
/** Packet type identifiers. */
export enum PacketType {
/** Manifest / metadata packet. */
MANIFEST = 0,
/** Systematic (uncoded) data symbol. */
DATA_SYSTEMATIC = 1,
/** Fountain-coded (repaired) data symbol. */
DATA_CODED = 2,
DATA_SYSTEMATIC = 0,
/** Fountain-coded (repair) data symbol. */
DATA_CODED = 1,
}
// ─── Flag Bits ───────────────────────────────────────────────────────────────
@@ -42,93 +48,44 @@ export enum PacketType {
export enum Flags {
/** No flags set. */
NONE = 0,
/** Marks the last symbol in a generation. */
LAST_SYMBOL_IN_GENERATION = 1 << 0,
/** Payload contains padding bytes at the end. */
PAYLOAD_PADDED = 1 << 1,
/** Manifest is critical / first fragment. */
MANIFEST_CRITICAL = 1 << 2,
/** Payload is plain text (not a file). */
IS_TEXT = 1 << 0,
/** Payload is deflate-raw compressed. */
COMPRESSED = 1 << 1,
/** This packet belongs to the last generation. */
LAST_GENERATION = 1 << 2,
}
// ─── Profile IDs ─────────────────────────────────────────────────────────────
// ─── Single Hardcoded Profile ────────────────────────────────────────────────
/** QR profile identifiers. */
export enum ProfileId {
/** Robust profile: QR V20, ECC Q, K=16, R=16 (100% overhead, big modules). */
ROBUST = 0,
/** Balanced profile: QR V25, ECC Q, K=20, R=12 (60% overhead). */
BALANCED = 1,
/** Fast profile: QR V35, ECC M, K=24, R=8 (33% overhead). */
FAST = 2,
}
/** Number of source symbols per generation. */
export const K = 16;
// ─── Profile Config ──────────────────────────────────────────────────────────
/** Number of coded repair symbols per generation. */
export const R = 8;
/** ECC level type for QR code generation. */
export type EccLevel = 'L' | 'M' | 'Q' | 'H';
/** QR code version. */
export const QR_VERSION = 10;
/** Configuration for a QR transfer profile. */
export interface ProfileConfig {
/** QR code version (140). */
qrVersion: number;
/** Error correction level. */
eccLevel: EccLevel;
/** Number of source symbols per generation. */
k: number;
/** Number of coded (repaired) symbols per generation. */
r: number;
/** Inter-frame delay in centiseconds (cs). */
frameDelay: number;
/** Approximate maximum payload per packet in bytes. */
maxPacketPayload: number;
}
/** QR error correction level. */
export const ECC_LEVEL = 'M' as const;
/** Lookup of all defined profiles by their ProfileId. */
export const PROFILES: Record<ProfileId, ProfileConfig> = {
[ProfileId.ROBUST]: {
qrVersion: 20,
eccLevel: 'Q',
k: 16,
r: 16,
frameDelay: 30,
maxPacketPayload: 450,
},
[ProfileId.BALANCED]: {
qrVersion: 25,
eccLevel: 'Q',
k: 20,
r: 12,
frameDelay: 20,
maxPacketPayload: 683,
},
[ProfileId.FAST]: {
qrVersion: 35,
eccLevel: 'M',
k: 24,
r: 8,
frameDelay: 15,
maxPacketPayload: 1777,
},
};
/** Inter-frame delay in centiseconds (300 ms). */
export const FRAME_DELAY = 30;
/** Default profile (Robust, for robustness-priority). */
export const DEFAULT_PROFILE_ID = ProfileId.ROBUST;
// ─── Session ID ──────────────────────────────────────────────────────────────
/**
* Generate a random 64-bit session identifier.
* Generate a random 32-bit session identifier.
*
* Uses `crypto.getRandomValues` to produce 8 cryptographically
* random bytes, then interprets them as a little-endian unsigned
* 64-bit bigint.
* Uses `crypto.getRandomValues` for 4 random bytes.
*
* @returns A random 64-bit session ID
* @returns A random 32-bit unsigned integer
*/
export function createSessionId(): bigint {
const buf = new Uint8Array(8);
export function createSessionId(): number {
const buf = new Uint8Array(4);
crypto.getRandomValues(buf);
let val = 0n;
for (let i = 0; i < 8; i++) {
val |= BigInt(buf[i]) << BigInt(i * 8);
}
return val;
return (
(buf[0]! | (buf[1]! << 8) | (buf[2]! << 16) | (buf[3]! << 24)) >>> 0
);
}
-269
View File
@@ -1,269 +0,0 @@
/**
* Manifest schema, serialization, and fragmentation support.
*
* The manifest carries session-level metadata encoded in CBOR (via the
* `cbor-x` library). If the serialized manifest exceeds a single packet's
* payload capacity it is fragmented across multiple MANIFEST-type packets.
*
* Fragments use the following packet header conventions:
* - `generation_index` = 0 (reserved for manifest)
* - `symbol_index` = fragment ordinal (0, 1, 2, …)
* - `generation_k` = total number of manifest fragments
* - `flags` = MANIFEST_CRITICAL on first fragment,
* LAST_SYMBOL_IN_GENERATION on last fragment
*
* @module
*/
import { encode, decode } from 'cbor-x';
import {
PacketType,
Flags,
ProfileId,
PROTOCOL_VERSION,
HEADER_SIZE,
CRC32C_SIZE,
} from './constants';
import { PacketHeader, createPacket, parsePacket } from './packet';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Content kind conveyed by this session. */
export type ContentKind = 'text' | 'file';
/** Compression codec identifier. */
export type CompressionCodec = 'none' | 'deflate-raw';
/** Decoded manifest metadata. */
export interface ManifestData {
/** Protocol version. */
protocolVersion: number;
/** Application version string. */
appVersion: string;
/** Unique 64-bit session identifier. */
sessionId: bigint;
/** Original file name (empty for text). */
originalFilename: string;
/** MIME type of the content. */
mimeType: string;
/** Kind of content: 'text' or 'file'. */
contentKind: ContentKind;
/** Size of the original data in bytes. */
originalSize: number;
/** Size after preprocessing (before framing) in bytes. */
preprocessedSize: number;
/** Compression codec applied during preprocessing. */
compressionCodec: CompressionCodec;
/** SHA-256 hex digest of the original data. */
originalSha256: string;
/** QR profile identifier used for this session. */
qrProfile: ProfileId;
/** Packet payload size in bytes. */
packetPayloadSize: number;
/** Number of source symbols per generation (K). */
generationK: number;
/** Number of coded symbols generated per generation (R). */
codedPerGen: number;
/** Total number of generations in the session. */
totalGenerations: number;
/** Actual size (in symbols) of the last generation. */
lastGenRealSize: number;
/** GIF frame delay in centiseconds. */
gifFrameDelay: number;
/** Loop parameters (0 = loop forever, >0 = repeat count). */
loopParams: number;
}
// ─── CBOR Field Names (short keys for compactness) ──────────────────────────
interface ManifestEncoded {
pv: number; // protocolVersion
av: string; // appVersion
si: string; // sessionId (as decimal string for CBOR safety)
of: string; // originalFilename
mt: string; // mimeType
ck: ContentKind; // contentKind
os: number; // originalSize
ps: number; // preprocessedSize
cc: CompressionCodec; // compressionCodec
oh: string; // originalSha256
qp: number; // qrProfile
pp: number; // packetPayloadSize
gk: number; // generationK
cg: number; // codedPerGen
tg: number; // totalGenerations
lr: number; // lastGenRealSize
fd: number; // gifFrameDelay
lp: number; // loopParams
}
// ─── Serialization ───────────────────────────────────────────────────────────
/**
* Serialize a ManifestData structure into CBOR-encoded bytes.
*
* Field names are shortened to 2-letter keys for compactness.
* The sessionId (bigint) is stored as a decimal string so it
* round-trips safely through any CBOR decoder.
*
* @param manifest - The manifest data to encode
* @returns CBOR-encoded Uint8Array
*/
export function encodeManifest(manifest: ManifestData): Uint8Array {
const obj: ManifestEncoded = {
pv: manifest.protocolVersion,
av: manifest.appVersion,
si: manifest.sessionId.toString(),
of: manifest.originalFilename,
mt: manifest.mimeType,
ck: manifest.contentKind,
os: manifest.originalSize,
ps: manifest.preprocessedSize,
cc: manifest.compressionCodec,
oh: manifest.originalSha256,
qp: manifest.qrProfile,
pp: manifest.packetPayloadSize,
gk: manifest.generationK,
cg: manifest.codedPerGen,
tg: manifest.totalGenerations,
lr: manifest.lastGenRealSize,
fd: manifest.gifFrameDelay,
lp: manifest.loopParams,
};
return encode(obj);
}
/**
* Deserialize CBOR-encoded bytes into a ManifestData structure.
*
* @param data - CBOR-encoded manifest bytes
* @returns The decoded manifest data
*/
export function decodeManifest(data: Uint8Array): ManifestData {
const obj = decode(data) as ManifestEncoded;
return {
protocolVersion: obj.pv,
appVersion: obj.av,
sessionId: BigInt(obj.si),
originalFilename: obj.of,
mimeType: obj.mt,
contentKind: obj.ck,
originalSize: obj.os,
preprocessedSize: obj.ps,
compressionCodec: obj.cc,
originalSha256: obj.oh,
qrProfile: obj.qp as ProfileId,
packetPayloadSize: obj.pp,
generationK: obj.gk,
codedPerGen: obj.cg,
totalGenerations: obj.tg,
lastGenRealSize: obj.lr,
gifFrameDelay: obj.fd,
loopParams: obj.lp,
};
}
// ─── Fragmentation ───────────────────────────────────────────────────────────
/**
* Build a single MANIFEST-type packet for one fragment of the manifest.
*
* @param manifest - The full manifest (used for header fields)
* @param fragmentData - This fragment's CBOR byte slice
* @param fragmentIndex - Zero-based fragment index
* @param totalFragments - Total number of fragments
* @returns A complete transport packet (ready for QR encoding)
*/
export function createManifestPacket(
manifest: ManifestData,
fragmentData: Uint8Array,
fragmentIndex: number,
totalFragments: number,
): Uint8Array {
const isFirst = fragmentIndex === 0;
const isLast = fragmentIndex === totalFragments - 1;
let flags = 0;
if (isFirst) flags |= Flags.MANIFEST_CRITICAL;
if (isLast) flags |= Flags.LAST_SYMBOL_IN_GENERATION;
const header: PacketHeader = {
protocolVersion: PROTOCOL_VERSION,
packetType: PacketType.MANIFEST,
flags,
profileId: manifest.qrProfile,
sessionId: manifest.sessionId,
generationIndex: 0, // Manifest uses generation index 0
symbolIndex: fragmentIndex,
generationK: totalFragments,
payloadLength: fragmentData.length,
codingSeed: 0, // Not used for manifest packets
};
return createPacket(header, fragmentData);
}
/**
* Fragment a serialized manifest into multiple transport packets.
*
* Each fragment's payload fits within `maxPayloadSize` bytes, and the
* fragment metadata (fragment index, total count) is carried in the
* packet header fields (`symbol_index`, `generation_k`).
*
* @param manifest - The manifest to fragment
* @param maxPayloadSize - Maximum payload per packet (typically the
* profile's maxPacketPayload)
* @returns An array of complete transport packets
*/
export function fragmentManifest(
manifest: ManifestData,
maxPayloadSize: number,
): Uint8Array[] {
const encoded = encodeManifest(manifest);
const totalFragments = Math.max(
1,
Math.ceil(encoded.length / maxPayloadSize),
);
const fragments: Uint8Array[] = [];
for (let i = 0; i < totalFragments; i++) {
const start = i * maxPayloadSize;
const end = Math.min(start + maxPayloadSize, encoded.length);
const chunk = encoded.slice(start, end);
fragments.push(createManifestPacket(manifest, chunk, i, totalFragments));
}
return fragments;
}
/**
* Reassemble and decode a manifest from its fragmented transport packets.
*
* Accepts an array of raw packet bytes, parses them, sorts by
* `symbolIndex`, concatenates the payloads in order, and decodes
* the CBOR manifest.
*
* @param packetBytes - Array of raw MANIFEST transport packets
* @returns The reassembled and decoded manifest
* @throws {Error} If no packets are provided or reassembly fails
*/
export function defragmentManifest(packetBytes: Uint8Array[]): ManifestData {
if (packetBytes.length === 0) {
throw new Error('No manifest packets to defragment');
}
// Parse and sort by symbol index
const parsed = packetBytes.map((pb) => parsePacket(pb));
parsed.sort((a, b) => a.header.symbolIndex - b.header.symbolIndex);
// Concatenate payloads in order
const totalSize = parsed.reduce((sum, p) => sum + p.payload.length, 0);
const combined = new Uint8Array(totalSize);
let offset = 0;
for (const p of parsed) {
combined.set(p.payload, offset);
offset += p.payload.length;
}
return decodeManifest(combined);
}
+50 -122
View File
@@ -1,23 +1,21 @@
/**
* Transport packet serialization and deserialization.
*
* Packet format (all multi-byte fields are little-endian):
* Fixed 18-byte header (all multi-byte fields are little-endian):
*
* | Offset | Size | Field | Description |
* |--------|------|--------------------|--------------------------------------|
* | 0 | 2 | magic | 'QG' (0x51, 0x47) |
* | 2 | 1 | protocol_version | 1 |
* | 3 | 1 | packet_type | 0=MANIFEST, 1=DATA_SYSTEMATIC, 2=CODED |
* | 4 | 1 | flags | bit flags |
* | 5 | 1 | profile_id | 0=Robust, 1=Balanced, 2=Fast |
* | 6 | 8 | session_id | random 64-bit |
* | 14 | 4 | generation_index | 32-bit |
* | 18 | 2 | symbol_index | 16-bit |
* | 20 | 2 | generation_k | number of source symbols |
* | 22 | 2 | payload_length | 16-bit |
* | 24 | 4 | coding_seed | 0 for systematic |
* | 28 | N | payload | variable-length payload |
* | 28+N | 4 | packet_crc32c | CRC32C over bytes 027 + payload |
* | 2 | 1 | protocol_version | 2 |
* | 3 | 1 | flags | IS_TEXT, COMPRESSED, LAST_GEN |
* | 4 | 4 | session_id | random 32-bit |
* | 8 | 2 | generation_index | 16-bit unsigned |
* | 10 | 2 | total_generations | 16-bit unsigned |
* | 12 | 1 | symbol_index | 8-bit unsigned |
* | 13 | 1 | packet_type | 0=SYSTEMATIC, 1=CODED |
* | 14 | 4 | data_length | 32-bit unsigned (preprocessed size) |
* | 18 | N | payload | fixed 191 bytes (zero-padded) |
* | 18+N | 4 | packet_crc32c | CRC32C over bytes 017 + payload |
*
* @module
*/
@@ -28,19 +26,17 @@ import {
HEADER_SIZE,
CRC32C_SIZE,
PacketType,
ProfileId,
MAX_PAYLOAD_SIZE,
} from './constants';
import { crc32c } from './crc32c';
// ─── Read/Write helpers (Little-Endian) ──────────────────────────────────────
// ─── Read/Write helpers (Little-Endian) ────────────────────────────────
/** Write a 16-bit unsigned value in little-endian format. */
function writeUint16LE(data: Uint8Array, offset: number, value: number): void {
data[offset] = value & 0xff;
data[offset + 1] = (value >>> 8) & 0xff;
}
/** Write a 32-bit unsigned value in little-endian format. */
function writeUint32LE(data: Uint8Array, offset: number, value: number): void {
data[offset] = value & 0xff;
data[offset + 1] = (value >>> 8) & 0xff;
@@ -48,106 +44,61 @@ function writeUint32LE(data: Uint8Array, offset: number, value: number): void {
data[offset + 3] = (value >>> 24) & 0xff;
}
/** Write a 64-bit unsigned bigint in little-endian format. */
function writeBigUint64LE(data: Uint8Array, offset: number, value: bigint): void {
const lo = Number(value & 0xffffffffn);
const hi = Number((value >> 32n) & 0xffffffffn);
writeUint32LE(data, offset, lo);
writeUint32LE(data, offset + 4, hi);
}
/** Read a 16-bit unsigned value in little-endian format. */
function readUint16LE(data: Uint8Array, offset: number): number {
return (data[offset] | (data[offset + 1] << 8)) >>> 0;
return (data[offset]! | (data[offset + 1]! << 8)) >>> 0;
}
/** Read a 32-bit unsigned value in little-endian format. */
function readUint32LE(data: Uint8Array, offset: number): number {
return (
data[offset] |
(data[offset + 1] << 8) |
(data[offset + 2] << 16) |
((data[offset + 3] << 24) >>> 0)
data[offset]! |
(data[offset + 1]! << 8) |
(data[offset + 2]! << 16) |
((data[offset + 3]! << 24) >>> 0)
) >>> 0;
}
/** Read a 64-bit unsigned bigint in little-endian format. */
function readBigUint64LE(data: Uint8Array, offset: number): bigint {
const lo = BigInt(readUint32LE(data, offset));
const hi = BigInt(readUint32LE(data, offset + 4));
return (hi << 32n) | lo;
}
// ─── Types ───────────────────────────────────────────────────────────────────
/** Decoded packet header fields. */
export interface PacketHeader {
/** Protocol version (expected: 1). */
protocolVersion: number;
/** Type of packet (MANIFEST / DATA_SYSTEMATIC / DATA_CODED). */
packetType: PacketType;
/** Bitfield of flag values (see Flags enum). */
flags: number;
/** Transfer profile identifier. */
profileId: ProfileId;
/** Unique 64-bit session identifier. */
sessionId: bigint;
/** Generation index within the session. */
sessionId: number;
generationIndex: number;
/** Symbol index within the generation. */
totalGenerations: number;
symbolIndex: number;
/** Number of source symbols in this generation (K). */
generationK: number;
/** Length of the payload in bytes. */
payloadLength: number;
/** Fountain coding seed (0 for systematic symbols). */
codingSeed: number;
packetType: PacketType;
dataLength: number;
}
/** A fully parsed packet with header and payload. */
export interface Packet {
/** Decoded header fields. */
header: PacketHeader;
/** Raw payload bytes (length = header.payloadLength). */
payload: Uint8Array;
}
// ─── Serialization ───────────────────────────────────────────────────────────
// ─── Serialization ─────────────────────────────────────────────────────────
/**
* Serialize a PacketHeader into a 28-byte fixed header buffer.
*
* @param header - The header to serialize
* @returns A new Uint8Array(28) containing the header bytes
* Serialize a PacketHeader into an 18-byte fixed header buffer.
*/
export function serializeHeader(header: PacketHeader): Uint8Array {
const buf = new Uint8Array(HEADER_SIZE);
// Magic
buf[0] = MAGIC_BYTES[0];
buf[1] = MAGIC_BYTES[1];
buf[0] = MAGIC_BYTES[0]!;
buf[1] = MAGIC_BYTES[1]!;
buf[2] = header.protocolVersion;
buf[3] = header.packetType;
buf[4] = header.flags;
buf[5] = header.profileId;
writeBigUint64LE(buf, 6, header.sessionId);
writeUint32LE(buf, 14, header.generationIndex);
writeUint16LE(buf, 18, header.symbolIndex);
writeUint16LE(buf, 20, header.generationK);
writeUint16LE(buf, 22, header.payloadLength);
writeUint32LE(buf, 24, header.codingSeed);
buf[3] = header.flags;
writeUint32LE(buf, 4, header.sessionId);
writeUint16LE(buf, 8, header.generationIndex);
writeUint16LE(buf, 10, header.totalGenerations);
buf[12] = header.symbolIndex;
buf[13] = header.packetType;
writeUint32LE(buf, 14, header.dataLength);
return buf;
}
/**
* Deserialize a 28-byte header buffer into a PacketHeader.
*
* @param data - Buffer containing at least 28 bytes
* @returns The decoded header
* @throws {Error} If the buffer is too short or magic bytes don't match
* Deserialize an 18-byte header buffer into a PacketHeader.
*/
export function parseHeader(data: Uint8Array): PacketHeader {
if (data.length < HEADER_SIZE) {
@@ -157,59 +108,44 @@ export function parseHeader(data: Uint8Array): PacketHeader {
}
if (data[0] !== MAGIC_BYTES[0] || data[1] !== MAGIC_BYTES[1]) {
throw new Error(
`Invalid magic bytes: expected 'QG' (0x51 0x47), got 0x${data[0].toString(16)} 0x${data[1].toString(16)}`
`Invalid magic bytes: expected 'QG' (0x51 0x47), got 0x${data[0]!.toString(16)} 0x${data[1]!.toString(16)}`
);
}
return {
protocolVersion: data[2],
packetType: data[3] as PacketType,
flags: data[4],
profileId: data[5] as ProfileId,
sessionId: readBigUint64LE(data, 6),
generationIndex: readUint32LE(data, 14),
symbolIndex: readUint16LE(data, 18),
generationK: readUint16LE(data, 20),
payloadLength: readUint16LE(data, 22),
codingSeed: readUint32LE(data, 24),
protocolVersion: data[2]!,
flags: data[3]!,
sessionId: readUint32LE(data, 4),
generationIndex: readUint16LE(data, 8),
totalGenerations: readUint16LE(data, 10),
symbolIndex: data[12]!,
packetType: data[13]! as PacketType,
dataLength: readUint32LE(data, 14),
};
}
/**
* Serialize a complete transport packet (header + payload + CRC32C trailer).
*
* @param header - The packet header
* @param payload - The payload bytes
* @returns A complete packet buffer ready for transmission
* Payload is always padded to exactly MAX_PAYLOAD_SIZE bytes before
* QR encoding, but the actual meaningful bytes are dataLength worth.
*/
export function createPacket(header: PacketHeader, payload: Uint8Array): Uint8Array {
const headerBytes = serializeHeader(header);
const totalLen = HEADER_SIZE + payload.length + CRC32C_SIZE;
const packet = new Uint8Array(totalLen);
// Header
packet.set(headerBytes, 0);
// Payload
packet.set(payload, HEADER_SIZE);
// CRC32C over header (027) + payload
const crcInput = new Uint8Array(HEADER_SIZE + payload.length);
crcInput.set(headerBytes, 0);
crcInput.set(payload, HEADER_SIZE);
const crc = crc32c(crcInput);
writeUint32LE(packet, HEADER_SIZE + payload.length, crc);
writeUint32LE(packet, HEADER_SIZE + payload.length, crc32c(crcInput));
return packet;
}
/**
* Deserialize and validate a complete transport packet.
*
* Verifies the magic bytes and CRC32C checksum on decode.
*
* @param data - Raw packet buffer
* @returns The decoded packet (header + payload)
* @throws {Error} If the buffer is too short, magic is wrong, or CRC mismatches
*/
export function parsePacket(data: Uint8Array): Packet {
if (data.length < HEADER_SIZE + CRC32C_SIZE) {
@@ -219,20 +155,12 @@ export function parsePacket(data: Uint8Array): Packet {
}
const header = parseHeader(data);
const payloadLength = header.payloadLength;
// Guard: ensure the buffer is large enough for the declared payload
if (HEADER_SIZE + payloadLength + CRC32C_SIZE > data.length) {
throw new Error(
`Packet truncated: declared payload ${payloadLength} bytes but buffer has ${data.length}`
);
}
const payload = data.slice(HEADER_SIZE, HEADER_SIZE + payloadLength);
const payloadLen = data.length - HEADER_SIZE - CRC32C_SIZE;
const payload = data.slice(HEADER_SIZE, HEADER_SIZE + payloadLen);
// Verify CRC32C
const storedCrc = readUint32LE(data, HEADER_SIZE + payloadLength);
const crcInput = new Uint8Array(HEADER_SIZE + payloadLength);
const storedCrc = readUint32LE(data, HEADER_SIZE + payloadLen);
const crcInput = new Uint8Array(HEADER_SIZE + payloadLen);
crcInput.set(data.slice(0, HEADER_SIZE), 0);
crcInput.set(payload, HEADER_SIZE);
const computedCrc = crc32c(crcInput);
+16 -51
View File
@@ -1,11 +1,8 @@
/**
* Payload reassembly from decoded RLNC generations.
*
* After all generations have been solved by the RLNC decoder, this module
* concatenates the source symbols in generation order and truncates the
* result to the original payload size (the last generation may contain
* fewer real symbols than K, and only `lastGenRealSize` symbols are taken
* from it).
* After all generations have been solved, concatenates the source symbols
* in generation order and trims the result to the exact data length.
*
* @module
*/
@@ -13,74 +10,42 @@
/**
* Assemble the original preprocessed payload from solved RLNC generations.
*
* Each generation contributes its K source symbols (Uint8Array values of
* equal length). For all but the last generation, all K symbols are used.
* For the last generation, only `lastGenRealSize` symbols are taken,
* because the remainder were zero-padding artefacts.
*
* @param solvedGenerations - Map from generation index to the array of
* K source symbols recovered by the decoder
* @param solvedGenerations - Map from generation index to the array of K source symbols
* @param totalGenerations - Total number of generations in the session
* @param lastGenRealSize - Number of *actual* (non-padding) symbols in
* the final generation (must be >= 1, <= K)
* @returns Concatenated payload bytes, truncated to the exact
* preprocessed size
* @param dataLength - Exact preprocessed size in bytes
* @returns Concatenated payload bytes, trimmed to dataLength
* @throws {Error} If a required generation is missing from the map
*/
export function assemblePayload(
solvedGenerations: Map<number, Uint8Array[]>,
totalGenerations: number,
lastGenRealSize: number,
dataLength: number,
): Uint8Array {
if (totalGenerations === 0) {
return new Uint8Array(0);
}
// First pass: validate inputs and compute total byte size
let totalSize = 0;
let symbolLength = 0;
const parts: Uint8Array[] = [];
for (let g = 0; g < totalGenerations; g++) {
const symbols = solvedGenerations.get(g);
if (!symbols || symbols.length === 0) {
throw new Error(
`assemblePayload: generation ${g} has no solved symbols` +
`ensure every generation has been decoded before assembly`,
`assemblePayload: generation ${g} has no solved symbols`
);
}
if (symbolLength === 0) {
symbolLength = symbols[0].length;
for (const sym of symbols) {
parts.push(sym);
}
const isLast = g === totalGenerations - 1;
const count = isLast ? lastGenRealSize : symbols.length;
if (count > symbols.length) {
throw new Error(
`assemblePayload: generation ${g} has ${symbols.length} symbols ` +
`but request requires ${count} (lastGenRealSize=${lastGenRealSize})`,
);
}
totalSize += symbolLength * count;
}
// Second pass: copy data
const result = new Uint8Array(totalSize);
const totalSize = parts.reduce((sum, p) => sum + p.length, 0);
const combined = new Uint8Array(totalSize);
let offset = 0;
for (let g = 0; g < totalGenerations; g++) {
const symbols = solvedGenerations.get(g)!;
const isLast = g === totalGenerations - 1;
const count = isLast ? lastGenRealSize : symbols.length;
for (let s = 0; s < count; s++) {
const sym = symbols[s];
result.set(sym, offset);
offset += sym.length;
}
for (const part of parts) {
combined.set(part, offset);
offset += part.length;
}
return result;
return combined.slice(0, dataLength);
}
+126 -256
View File
@@ -1,287 +1,157 @@
/**
* Sender-side packetizer.
* Sender-side packetizer — single profile, no manifest.
*
* Orchestrates the full encoding pipeline:
* 1. Compress raw data (deflate-raw, with heuristic)
* 2. Compute SHA-256 hash of original data
* 3. Split preprocessed data into source symbols
* 4. Group symbols into generations of K
* 5. Encode each generation with RLNC (K systematic + R coded symbols)
* 6. Create serialised transport packets for every symbol
* 7. Build the session manifest
*
* The resulting data packets (accessible via {@link getPackets}) are then
* handed to {@link FrameScheduler} for final interleaving and manifest
* preamble insertion.
* Steps:
* 1. Optional compression (deflate-raw)
* 2. Split preprocessed data into 191-byte symbols
* 3. Group into generations of K=16
* 4. RLNC encode each generation (16 systematic + 8 coded)
* 5. Build transport packets with metadata in every header
*
* @module
*/
import {
createSessionId,
DEFAULT_PROFILE_ID,
PacketType,
ProfileId,
PROFILES,
PROTOCOL_VERSION,
type ProfileConfig,
PacketType,
Flags,
K,
R,
MAX_PAYLOAD_SIZE,
createSessionId,
} from '@/core/protocol/constants';
import { createPacket, type PacketHeader } from '@/core/protocol/packet';
import type { ManifestData } from '@/core/protocol/manifest';
import { PacketHeader, createPacket } from '@/core/protocol/packet';
import { encodeGeneration } from '@/core/fec/rlnc_encoder';
import { compress, shouldCompress } from '@/core/preprocess/compress';
import { sha256Hex } from '@/core/preprocess/hash';
import { deflateSync } from 'fflate';
// ─── Default Coding Seed ─────────────────────────────────────────────────────
// ─── Types ───────────────────────────────────────────────────────────────────
/**
* Fixed coding seed used for RLNC coefficient derivation on the sender side.
*
* A fixed value is acceptable because per-generation uniqueness is provided
* by the combination of `sessionId`, `generationIndex`, and the per-symbol
* index mixing — see `deriveCoefficientSeed` and the encoder loop.
*/
const DEFAULT_CODING_SEED = 42;
// ─── Exports ─────────────────────────────────────────────────────────────────
export interface PacketizerProgress {
totalPackets: number;
currentPacket: number;
export interface PacketizerResult {
packets: Uint8Array[];
sessionId: number;
totalGenerations: number;
dataLength: number;
isText: boolean;
isCompressed: boolean;
}
// ─── Packetizer ──────────────────────────────────────────────────────────────────
/**
* Sender-side packetizer.
* Encode raw data into transport packets.
*
* Usage:
* ```ts
* const pktz = new SenderPacketizer(ProfileId.BALANCED);
* await pktz.initialize(data, 'photo.jpg', 'image/jpeg');
* const manifest = pktz.getManifest();
* const packets = pktz.getPackets();
* ```
* @param data Raw bytes to transmit
* @param isText Whether the payload is plain text
* @param compress Whether to apply deflate-raw compression
* @returns PacketizerResult containing all packets and metadata
*/
export class SenderPacketizer {
private readonly profileId: ProfileId;
private readonly profileConfig: ProfileConfig;
private readonly sessionId: bigint;
export function packetize(
data: Uint8Array,
isText: boolean,
compress: boolean,
): PacketizerResult {
// 1. Optional compression
let preprocessed: Uint8Array;
let isCompressed: boolean;
private _initialized = false;
private _manifest: ManifestData | null = null;
private _packets: Uint8Array[] = [];
private _totalPackets = 0;
private _currentPacket = 0;
/**
* @param profileId - QR transfer profile (defaults to {@link DEFAULT_PROFILE_ID})
*/
constructor(profileId: ProfileId = DEFAULT_PROFILE_ID) {
this.profileId = profileId;
this.profileConfig = PROFILES[profileId];
this.sessionId = createSessionId();
if (compress && data.length > 64) {
preprocessed = deflateSync(data);
isCompressed = true;
} else {
preprocessed = new Uint8Array(data);
isCompressed = false;
}
/**
* Run the full encoding pipeline.
*
* Steps:
* 1. Compress the input with deflate-raw and decide (via
* {@link shouldCompress}) whether to keep the compressed version.
* 2. Compute the SHA-256 hex digest of the **original** data for integrity.
* 3. Split the preprocessed bytes into fixed-size source symbols of
* `maxPacketPayload` bytes (final symbol is zero-padded).
* 4. Group symbols into generations of K and pad the last generation
* with zero symbols if it is short.
* 5. Encode each generation with RLNC to produce K systematic + R coded
* symbols.
* 6. Serialise every symbol as a transport packet.
* 7. Construct the session manifest.
*
* @param data - Raw bytes to transmit
* @param filename - Optional file name (stored in manifest)
* @param mime - Optional MIME type (stored in manifest)
*/
async initialize(
data: Uint8Array,
filename?: string,
mime?: string,
): Promise<void> {
// ── 1. Compression ────────────────────────────────────────────────────
const compressed = await compress(data);
const useCompression = shouldCompress(data, compressed);
const preprocessed = useCompression ? compressed : data;
const compressionCodec = useCompression ? 'deflate-raw' : 'none';
const dataLength = preprocessed.length;
// ── 2. Hash original ──────────────────────────────────────────────────
const hashHex = await sha256Hex(data);
// 2. Split into fixed-size symbols
const symbols: Uint8Array[] = [];
for (let offset = 0; offset < dataLength; offset += MAX_PAYLOAD_SIZE) {
const chunk = preprocessed.slice(offset, offset + MAX_PAYLOAD_SIZE);
if (chunk.length < MAX_PAYLOAD_SIZE) {
const padded = new Uint8Array(MAX_PAYLOAD_SIZE);
padded.set(chunk);
symbols.push(padded);
} else {
symbols.push(chunk);
}
}
// ── 3. Split into fixed-size source symbols ───────────────────────────
const symbolSize = this.profileConfig.maxPacketPayload;
const numDataSymbols = Math.max(
1,
Math.ceil(preprocessed.length / symbolSize),
const totalSymbols = symbols.length;
const totalGenerations = Math.max(1, Math.ceil(totalSymbols / K));
const sessionId = createSessionId();
const codingSeed = 0;
// 3. Encode generations and build packets
const packets: Uint8Array[] = [];
for (let gen = 0; gen < totalGenerations; gen++) {
const startIdx = gen * K;
const genSymbolsCount = Math.min(K, totalSymbols - startIdx);
const isLastGen = gen === totalGenerations - 1;
const genSourceSymbols: Uint8Array[] = [];
for (let i = 0; i < K; i++) {
if (i < genSymbolsCount) {
genSourceSymbols.push(symbols[startIdx + i]!);
} else {
genSourceSymbols.push(new Uint8Array(MAX_PAYLOAD_SIZE));
}
}
const codedSymbols = encodeGeneration(
genSourceSymbols,
K,
R,
sessionId,
gen,
codingSeed,
);
const sourceSymbols: Uint8Array[] = [];
for (let i = 0; i < numDataSymbols; i++) {
const offset = i * symbolSize;
const sym = new Uint8Array(symbolSize); // zero-filled
const flagsBase =
(isText ? Flags.IS_TEXT : 0) |
(isCompressed ? Flags.COMPRESSED : 0) |
(isLastGen ? Flags.LAST_GENERATION : 0);
if (offset < preprocessed.length) {
const end = Math.min(offset + symbolSize, preprocessed.length);
sym.set(preprocessed.subarray(offset, end), 0);
}
// Remaining bytes stay zero (padding for the last symbol)
sourceSymbols.push(sym);
// Systematic symbols
for (let i = 0; i < K; i++) {
const cs = codedSymbols[i]!;
const header: PacketHeader = {
protocolVersion: PROTOCOL_VERSION,
flags: flagsBase,
sessionId,
generationIndex: gen,
totalGenerations,
symbolIndex: cs.sourceIndex,
packetType: PacketType.DATA_SYSTEMATIC,
dataLength,
};
packets.push(createPacket(header, cs.data));
}
// ── 4. Group into generations ─────────────────────────────────────────
const k = this.profileConfig.k;
const r = this.profileConfig.r;
const totalGenerations = Math.max(
1,
Math.ceil(numDataSymbols / k),
);
const lastGenRealSize =
numDataSymbols % k === 0 ? k : numDataSymbols % k;
// Narrow the 64-bit session ID to 32 bits for the RLNC encoder
// (deriveCoefficientSeed only uses the lower 32 bits).
const sessionIdNum = Number(this.sessionId & 0xffffffffn);
const codingSeed = DEFAULT_CODING_SEED;
// ── 5 + 6. Encode each generation, build packets ──────────────────────
const dataPackets: Uint8Array[] = [];
for (let gen = 0; gen < totalGenerations; gen++) {
const startIdx = gen * k;
const endIdx = Math.min(startIdx + k, numDataSymbols);
// Collect this generation's source symbols (or empty if beyond payload)
const genSource: Uint8Array[] = [];
for (let i = startIdx; i < endIdx; i++) {
genSource.push(sourceSymbols[i]);
}
// Pad the last generation with zero symbols if needed
while (genSource.length < k) {
genSource.push(new Uint8Array(symbolSize));
}
// RLNC encode → K systematic + R coded symbols
const encoded = encodeGeneration(
genSource,
k,
r,
sessionIdNum,
gen,
codingSeed,
);
// Serialise each symbol as a transport packet
for (let symIdx = 0; symIdx < encoded.length; symIdx++) {
const cs = encoded[symIdx];
let packetType: PacketType;
let symbolIndex: number;
let seed: number;
if (cs.isSystematic) {
packetType = PacketType.DATA_SYSTEMATIC;
symbolIndex = cs.sourceIndex; // 0 … K-1
seed = 0;
} else {
packetType = PacketType.DATA_CODED;
symbolIndex = symIdx - k; // 0 … R-1
seed = codingSeed;
}
const header: PacketHeader = {
protocolVersion: PROTOCOL_VERSION,
packetType,
flags: 0,
profileId: this.profileId,
sessionId: this.sessionId,
generationIndex: gen,
symbolIndex,
generationK: k,
payloadLength: cs.data.length,
codingSeed: seed,
};
dataPackets.push(createPacket(header, cs.data));
}
this._currentPacket = dataPackets.length;
// Coded symbols
for (let j = 0; j < R; j++) {
const cs = codedSymbols[K + j]!;
const header: PacketHeader = {
protocolVersion: PROTOCOL_VERSION,
flags: flagsBase,
sessionId,
generationIndex: gen,
totalGenerations,
symbolIndex: j,
packetType: PacketType.DATA_CODED,
dataLength,
};
packets.push(createPacket(header, cs.data));
}
// ── 7. Build manifest ─────────────────────────────────────────────────
this._manifest = {
protocolVersion: PROTOCOL_VERSION,
appVersion: '1.0.0',
sessionId: this.sessionId,
originalFilename: filename ?? '',
mimeType: mime ?? 'application/octet-stream',
contentKind: filename ? 'file' : 'text',
originalSize: data.length,
preprocessedSize: preprocessed.length,
compressionCodec,
originalSha256: hashHex,
qrProfile: this.profileId,
packetPayloadSize: symbolSize,
generationK: k,
codedPerGen: r,
totalGenerations,
lastGenRealSize,
gifFrameDelay: this.profileConfig.frameDelay,
loopParams: 0,
};
this._packets = dataPackets;
this._totalPackets = dataPackets.length;
this._initialized = true;
}
// ─── Accessors ───────────────────────────────────────────────────────────
/**
* The constructed session manifest.
*
* @throws {Error} If called before {@link initialize}
*/
getManifest(): ManifestData {
if (!this._initialized || !this._manifest) {
throw new Error('SenderPacketizer: not initialized');
}
return this._manifest;
}
/**
* All data packets (systematic + coded), serialised as complete transport
* packets ready for QR encoding.
*
* Does **not** include manifest packets — those are produced separately
* via `fragmentManifest()` in the scheduler.
*
* @throws {Error} If called before {@link initialize}
*/
getPackets(): Uint8Array[] {
if (!this._initialized) {
throw new Error('SenderPacketizer: not initialized');
}
return this._packets;
}
/**
* Encoding progress.
*
* `totalPackets` is the total number of data packets that will be
* produced; `currentPacket` reflects how many have been built so far
* (equal to `totalPackets` once {@link initialize} completes).
*/
getProgress(): PacketizerProgress {
return {
totalPackets: this._totalPackets,
currentPacket: this._currentPacket,
};
}
return {
packets,
sessionId,
totalGenerations,
dataLength,
isText,
isCompressed,
};
}
+73 -155
View File
@@ -1,48 +1,21 @@
/**
* Frame scheduler — creates the final ordered frame sequence.
*
* Per the protocol specification (§11), the output GIF frame sequence
* is structured as:
*
* 1. **Preamble** — All manifest fragments (one QR frame per fragment).
* 2. **Body Phase A (Systematic interleaving)** — Systematic symbols
* are emitted across generations in a permuted order, one symbol
* per generation at a time.
* 3. **Body Phase B (Coded interleaving)** — Coded repair symbols are
* likewise emitted in the same permuted generation order.
*
* The generation permutation is deterministic (Fisher-Yates seeded from
* the lower 32 bits of the session ID) so that the receiver can reproduce
* the expected arrival order.
*
* Every 7 data frames a manifest reinsertion occurs, providing a decoding
* entry point for receivers that join mid-transmission.
* Simply interleaves systematic symbols across generations,
* then coded symbols across generations. No manifest preamble.
*
* @module
*/
import { PacketType } from '@/core/protocol/constants';
import { parseHeader } from '@/core/protocol/packet';
import { fragmentManifest, type ManifestData } from '@/core/protocol/manifest';
import { Xoshiro128 } from '@/core/fec/xoshiro';
// ─── Seeded Fisher-Yates Shuffle ─────────────────────────────────────────────
/**
* Deterministically shuffle an array using a seeded xoshiro128** PRNG.
*
* The same seed always produces the same permutation.
*
* @param arr - Input array (not mutated)
* @param seed - 32-bit seed for the PRNG
* @returns A new array with elements permuted
*/
function seededShuffle<T>(arr: readonly T[], seed: number): T[] {
const rng = new Xoshiro128(seed);
const result = [...arr];
for (let i = result.length - 1; i > 0; i--) {
const j = rng.next() % (i + 1);
// Swap
const tmp = result[i]!;
result[i] = result[j]!;
result[j] = tmp;
@@ -50,139 +23,84 @@ function seededShuffle<T>(arr: readonly T[], seed: number): T[] {
return result;
}
// ─── FrameScheduler ──────────────────────────────────────────────────────────
/**
* Creates the final ordered frame sequence for QR-over-GIF transmission.
*
* The scheduler takes the raw data packets produced by {@link SenderPacketizer}
* together with the session manifest, and produces an ordered list of
* serialised packet bytes that the QR/GIF pipeline encodes frame-by-frame.
* @param packets All data packets from packetizer
* @param totalGenerations Total number of generations
* @param sessionId Session identifier (for deterministic shuffle)
* @returns Ordered array of serialised packet bytes
*/
export class FrameScheduler {
/**
* Build the complete frame sequence.
*
* @param packets - Serialised data packets (systematic + coded) from
* {@link SenderPacketizer.getPackets}
* @param manifest - The session manifest produced by
* {@link SenderPacketizer.getManifest}
* @returns Ordered array of serialised packet bytes, each representing
* one QR frame in the output GIF
*/
schedule(
packets: Uint8Array[],
manifest: ManifestData,
): Uint8Array[] {
// ── 0. Fragment manifest into preamble packets ─────────────────────────
const manifestPackets = fragmentManifest(manifest, manifest.packetPayloadSize);
export function scheduleFrames(
packets: Uint8Array[],
totalGenerations: number,
sessionId: number,
): Uint8Array[] {
// Separate by type and group by generation
const sysSymbols = new Map<number, Map<number, Uint8Array>>();
const codedSymbols = new Map<number, Map<number, Uint8Array>>();
// ── 1. Separate packets by type and group by generation ─────────────────
const genSys: Map<number, Uint8Array[]> = new Map();
const genCoded: Map<number, Uint8Array[]> = new Map();
// Also sort packets within each generation by symbolIndex so they are
// emitted in a deterministic order.
const sysSymbols: Map<number, Map<number, Uint8Array>> = new Map();
const codedSymbols: Map<number, Map<number, Uint8Array>> = new Map();
for (const pkt of packets) {
// parseHeader only reads the first 28 bytes — no CRC validation,
// which is fine since we built these packets ourselves.
const header = parseHeader(pkt);
if (header.packetType === PacketType.DATA_SYSTEMATIC) {
let genMap = sysSymbols.get(header.generationIndex);
if (!genMap) {
genMap = new Map();
sysSymbols.set(header.generationIndex, genMap);
}
genMap.set(header.symbolIndex, pkt);
} else if (header.packetType === PacketType.DATA_CODED) {
let genMap = codedSymbols.get(header.generationIndex);
if (!genMap) {
genMap = new Map();
codedSymbols.set(header.generationIndex, genMap);
}
genMap.set(header.symbolIndex, pkt);
for (const pkt of packets) {
const header = parseHeader(pkt);
if (header.packetType === PacketType.DATA_SYSTEMATIC) {
let genMap = sysSymbols.get(header.generationIndex);
if (!genMap) {
genMap = new Map();
sysSymbols.set(header.generationIndex, genMap);
}
}
// Convert the inner maps to sorted arrays
for (const [genIdx, symMap] of sysSymbols) {
const sorted = Array.from(symMap.entries())
.sort((a, b) => a[0] - b[0])
.map(([, pkt]) => pkt);
genSys.set(genIdx, sorted);
}
for (const [genIdx, symMap] of codedSymbols) {
const sorted = Array.from(symMap.entries())
.sort((a, b) => a[0] - b[0])
.map(([, pkt]) => pkt);
genCoded.set(genIdx, sorted);
}
// ── 2. Generate generation permutation ─────────────────────────────────
const genIndices: number[] = [];
for (let i = 0; i < manifest.totalGenerations; i++) {
genIndices.push(i);
}
// Mix both halves of the 64-bit session ID for a more distributed seed
const seed =
(Number(manifest.sessionId & 0xffffffffn) >>> 0) ^
(Number((manifest.sessionId >> 32n) & 0xffffffffn) >>> 0);
const permutedGens = seededShuffle(genIndices, seed);
// ── 3. Build frame sequence ────────────────────────────────────────────
const frames: Uint8Array[] = [];
let dataFrameCount = 0;
/**
* Helper: push a manifest reinsertion when due.
*
* Every 7 data frames (after the preamble) we re-emit all manifest
* fragments so late-joining receivers can decode.
*/
const maybeInsertManifest = (): void => {
if (dataFrameCount > 0 && dataFrameCount % 7 === 0) {
for (const mp of manifestPackets) {
frames.push(mp);
}
genMap.set(header.symbolIndex, pkt);
} else if (header.packetType === PacketType.DATA_CODED) {
let genMap = codedSymbols.get(header.generationIndex);
if (!genMap) {
genMap = new Map();
codedSymbols.set(header.generationIndex, genMap);
}
};
// ── Preamble ───────────────────────────────────────────────────────────
for (const mp of manifestPackets) {
frames.push(mp);
genMap.set(header.symbolIndex, pkt);
}
// ── Body Phase A: Systematic interleaving ──────────────────────────────
const k = manifest.generationK;
for (let symIdx = 0; symIdx < k; symIdx++) {
for (const genIdx of permutedGens) {
const genPkts = genSys.get(genIdx);
if (!genPkts || symIdx >= genPkts.length) continue;
maybeInsertManifest();
frames.push(genPkts[symIdx]!);
dataFrameCount++;
}
}
// ── Body Phase B: Coded interleaving ───────────────────────────────────
const r = manifest.codedPerGen;
for (let symIdx = 0; symIdx < r; symIdx++) {
for (const genIdx of permutedGens) {
const genPkts = genCoded.get(genIdx);
if (!genPkts || symIdx >= genPkts.length) continue;
maybeInsertManifest();
frames.push(genPkts[symIdx]!);
dataFrameCount++;
}
}
return frames;
}
// Convert to sorted arrays per generation
const genSys = new Map<number, Uint8Array[]>();
const genCoded = new Map<number, Uint8Array[]>();
for (const [genIdx, symMap] of sysSymbols) {
const sorted = Array.from(symMap.entries())
.sort((a, b) => a[0] - b[0])
.map(([, pkt]) => pkt);
genSys.set(genIdx, sorted);
}
for (const [genIdx, symMap] of codedSymbols) {
const sorted = Array.from(symMap.entries())
.sort((a, b) => a[0] - b[0])
.map(([, pkt]) => pkt);
genCoded.set(genIdx, sorted);
}
// Generation permutation
const genIndices: number[] = [];
for (let i = 0; i < totalGenerations; i++) genIndices.push(i);
const permutedGens = seededShuffle(genIndices, sessionId);
// Build frame sequence
const frames: Uint8Array[] = [];
// Phase A: Systematic interleaving
for (let symIdx = 0; symIdx < 16; symIdx++) {
for (const genIdx of permutedGens) {
const genPkts = genSys.get(genIdx);
if (!genPkts || symIdx >= genPkts.length) continue;
frames.push(genPkts[symIdx]!);
}
}
// Phase B: Coded interleaving
for (let symIdx = 0; symIdx < 8; symIdx++) {
for (const genIdx of permutedGens) {
const genPkts = genCoded.get(genIdx);
if (!genPkts || symIdx >= genPkts.length) continue;
frames.push(genPkts[symIdx]!);
}
}
return frames;
}
+426 -734
View File
File diff suppressed because it is too large Load Diff
+34 -59
View File
@@ -1,73 +1,48 @@
/**
* Test that all frames in a generated GIF can be QR-decoded.
* Reproduces the issue where receiver shows fewer frames than sent.
* Frame decode test: verify QR encode -> decode roundtrip at the packet level.
*/
import { describe, it, expect } from 'vitest';
import { SenderPacketizer } from '@/core/sender/packetizer';
import { FrameScheduler } from '@/core/sender/scheduler';
import { packetize } from '@/core/sender/packetizer';
import { scheduleFrames } from '@/core/sender/scheduler';
import { generateQRMatrix } from '@/core/qr/qr_encode';
import { rasterizeQR } from '@/core/qr/frame_raster';
import { createQRGif } from '@/core/gif/gif_render';
import { parseGif, renderGifFrame } from '@/core/gif/gif_parser';
import { decodeQRFromCanvas } from '@/core/qr/qr_decode';
import { ProfileId } from '@/core/protocol/constants';
import { parsePacket } from '@/core/protocol/packet';
import { QR_VERSION, ECC_LEVEL } from '@/core/protocol/constants';
describe('Frame decode reliability', () => {
it('should decode all frames from a ~33 frame GIF', async () => {
// Find a payload that generates ~33 frames with ROBUST profile
// ROBUST: k=16, r=16, maxPayload=450
// 1 gen = 32 data frames. With M=1 manifest: 1 + 32 + 4 = 37 frames.
// Let's try a very small payload that might compress well and result
// in fewer data frames somehow... but the encoder always produces k+r.
// Actually, let's just test with 1 generation and verify ALL frames decode.
const text = 'Hello world this is a test of the QR GIF system.';
describe('Frame Decode', () => {
it('should decode every frame in a small transmission', async () => {
const text = 'Frame decode roundtrip test!';
const data = new TextEncoder().encode(text);
const result = packetize(data, false, false);
const frames = scheduleFrames(result.packets, result.totalGenerations, result.sessionId);
const sp = new SenderPacketizer(ProfileId.ROBUST);
await sp.initialize(data, 'test.txt', 'text/plain');
const manifest = sp.getManifest();
const packets = sp.getPackets();
const scheduler = new FrameScheduler();
const schedule = scheduler.schedule(packets, manifest);
expect(frames.length).toBeGreaterThan(0);
console.log('Schedule length:', schedule.length);
console.log('Generations:', manifest.totalGenerations);
for (let i = 0; i < frames.length; i++) {
const originalPacket = frames[i]!;
// Generate GIF
const scale = 3;
const qrFrames: Uint8Array[] = [];
let fw = 0, fh = 0;
for (const p of schedule) {
const matrix = generateQRMatrix(p, manifest.qrProfile === ProfileId.ROBUST ? 20 :
manifest.qrProfile === ProfileId.BALANCED ? 25 : 35,
manifest.qrProfile === ProfileId.FAST ? 'M' : 'Q');
const rgba = rasterizeQR(matrix, scale);
if (fw === 0) { fw = rgba.width; fh = rgba.height; }
qrFrames.push(new Uint8Array(rgba.data.buffer, rgba.data.byteOffset, rgba.data.byteLength));
// Encode as QR
const matrix = generateQRMatrix(originalPacket, QR_VERSION, ECC_LEVEL);
const imageData = rasterizeQR(matrix, 4);
// Decode QR
const decodedBytes = decodeQRFromCanvas(imageData);
expect(decodedBytes, `Frame ${i} failed to decode`).not.toBeNull();
// Parse and verify header fields match
const decoded = parsePacket(decodedBytes!);
const original = parsePacket(originalPacket);
expect(decoded.header.protocolVersion).toBe(original.header.protocolVersion);
expect(decoded.header.sessionId).toBe(original.header.sessionId);
expect(decoded.header.generationIndex).toBe(original.header.generationIndex);
expect(decoded.header.symbolIndex).toBe(original.header.symbolIndex);
expect(decoded.header.packetType).toBe(original.header.packetType);
expect(decoded.header.totalGenerations).toBe(original.header.totalGenerations);
expect(decoded.header.dataLength).toBe(original.header.dataLength);
expect(decoded.header.flags).toBe(original.header.flags);
expect(decoded.payload).toEqual(original.payload);
}
const gifBytes = createQRGif(qrFrames, 300, fw, fh);
const gifData = parseGif(gifBytes);
expect(gifData.frames.length).toBe(schedule.length);
console.log('GIF parsed frames:', gifData.frames.length);
// Decode every frame
let decodedCount = 0;
const failures: number[] = [];
for (let i = 0; i < gifData.frames.length; i++) {
const rgba = renderGifFrame(gifData, i);
const imageData = new ImageData(rgba, gifData.width, gifData.height);
const qrResult = decodeQRFromCanvas(imageData);
if (qrResult) {
decodedCount++;
} else {
failures.push(i);
}
}
console.log('Decoded:', decodedCount, '/', gifData.frames.length);
console.log('Failures:', failures);
expect(failures.length).toBe(0);
});
});
+50 -77
View File
@@ -1,101 +1,74 @@
/**
* End-to-end: encode QR GIF parse GIF decode QR reconstruct.
* Separate from complete.test.ts to avoid module state interactions.
* GIF roundtrip: encode data into GIF frames, parse them back, decode.
*/
import { describe, it, expect } from 'vitest';
import { SenderPacketizer } from '@/core/sender/packetizer';
import { FrameScheduler } from '@/core/sender/scheduler';
import { packetize } from '@/core/sender/packetizer';
import { scheduleFrames } from '@/core/sender/scheduler';
import { generateQRMatrix } from '@/core/qr/qr_encode';
import { rasterizeQR } from '@/core/qr/frame_raster';
import { createQRGif } from '@/core/gif/gif_render';
import { parseGif, gifFrameToRgba } from '@/core/gif/gif_parser';
import { decodeQRFromBuffer } from '@/core/qr/qr_decode';
import { parseGif, renderGifFrame } from '@/core/gif/gif_parser';
import { decodeQRFromCanvas } from '@/core/qr/qr_decode';
import { parsePacket } from '@/core/protocol/packet';
import { PacketType } from '@/core/protocol/constants';
import { GenerationDecoder } from '@/core/fec/rlnc_decoder';
import { assemblePayload } from '@/core/reconstruct/assemble';
import { PacketType, K, MAX_PAYLOAD_SIZE, QR_VERSION, ECC_LEVEL, FRAME_DELAY } from '@/core/protocol/constants';
describe('GIF roundtrip', () => {
it('should encode lorem ipsum, create GIF, parse and reconstruct', async () => {
const text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.';
const data = new TextEncoder().encode(text);
describe('GIF Roundtrip', () => {
it('should encode and decode a GIF', async () => {
const data = new TextEncoder().encode('GIF roundtrip test data');
const result = packetize(data, false, false);
const frames = scheduleFrames(result.packets, result.totalGenerations, result.sessionId);
const sp = new SenderPacketizer();
await sp.initialize(data, 'lorem.txt', 'text/plain');
const manifest = sp.getManifest();
const packets = sp.getPackets();
const scheduler = new FrameScheduler();
const schedule = scheduler.schedule(packets, manifest);
expect(schedule.length).toBeGreaterThan(0);
// QR frames
const scale = 3;
const qrFrames: Uint8Array[] = [];
let fw = 0, fh = 0;
for (const p of schedule) {
const matrix = generateQRMatrix(p, 20, 'Q');
const rgba = rasterizeQR(matrix, scale);
if (fw === 0) { fw = rgba.width; fh = rgba.height; }
qrFrames.push(new Uint8Array(rgba.data.buffer));
// Generate QR matrices and rasterize
const imageFrames: Uint8Array[] = [];
let width = 0;
let height = 0;
for (const frame of frames) {
const matrix = generateQRMatrix(frame, QR_VERSION, ECC_LEVEL);
const imageData = rasterizeQR(matrix, 4);
if (width === 0) {
width = imageData.width;
height = imageData.height;
}
imageFrames.push(new Uint8Array(imageData.data.buffer));
}
// GIF
const gifBytes = createQRGif(qrFrames, 300, fw, fh);
// Create GIF
const gifBytes = createQRGif(imageFrames, FRAME_DELAY * 10, width, height);
const gifData = parseGif(gifBytes);
expect(gifData.frames.length).toBe(schedule.length);
expect(gifData.frames.length).toBe(frames.length);
// Decode frames
const decoded: Uint8Array[] = [];
for (const frame of gifData.frames) {
const rgba = gifFrameToRgba(frame);
const gray = new Uint8Array(frame.width * frame.height);
for (let p = 0; p < frame.width * frame.height; p++) {
gray[p] = rgba[p * 4]! < 128 ? 0 : 255;
// Decode frames from GIF
const decoder = new GenerationDecoder(K, MAX_PAYLOAD_SIZE, result.sessionId, 0);
const solvedGens = new Set<number>();
for (let i = 0; i < gifData.frames.length; i++) {
const rgba = renderGifFrame(gifData, i);
const imageData = new ImageData(rgba, gifData.width, gifData.height);
const decodedBytes = decodeQRFromCanvas(imageData);
expect(decodedBytes, `GIF frame ${i} failed QR decode`).not.toBeNull();
const pkt = parsePacket(decodedBytes!);
if (pkt.header.packetType === PacketType.DATA_SYSTEMATIC) {
decoder.addSystematicSymbol(pkt.header.generationIndex, pkt.payload, pkt.header.symbolIndex);
} else {
decoder.addCodedSymbol(pkt.header.generationIndex, pkt.payload, pkt.header.symbolIndex);
}
const qr = decodeQRFromBuffer(gray, frame.width, frame.height);
if (qr) {
decoded.push(qr);
if (decoder.isSolved(pkt.header.generationIndex)) {
solvedGens.add(pkt.header.generationIndex);
}
}
expect(decoded.length).toBeGreaterThan(0);
// RLNC decode
const maxPl = manifest.packetPayloadSize;
const sid = Number(manifest.sessionId & BigInt('0xFFFFFFFF'));
const gd = new GenerationDecoder(manifest.generationK, maxPl, sid, 0);
expect(solvedGens.size).toBe(result.totalGenerations);
for (const bytes of decoded) {
try {
const p = parsePacket(bytes);
if (p.header.packetType === PacketType.DATA_SYSTEMATIC) {
const pl = p.payload;
const padded = pl.length < maxPl
? (() => { const p2 = new Uint8Array(maxPl); p2.set(pl); return p2; })()
: pl;
gd.addSystematicSymbol(p.header.generationIndex, padded, p.header.symbolIndex);
}
} catch { /* skip */ }
const solvedMap = new Map<number, Uint8Array[]>();
for (let g = 0; g < result.totalGenerations; g++) {
solvedMap.set(g, decoder.getSourceSymbols(g)!);
}
let ok = true;
for (let g = 0; g < manifest.totalGenerations; g++) {
if (!gd.isSolved(g)) { ok = false; break; }
}
expect(ok).toBe(true);
const gens = new Map<number, Uint8Array[]>();
for (let g = 0; g < manifest.totalGenerations; g++) {
gens.set(g, gd.getSourceSymbols(g)!);
}
const payload = assemblePayload(gens, manifest.totalGenerations, manifest.lastGenRealSize);
// Decompress if needed
let finalPayload = payload;
if (manifest.compressionCodec === 'deflate-raw') {
const { inflateSync } = await import('fflate');
finalPayload = inflateSync(payload);
}
expect(finalPayload.slice(0, data.length)).toEqual(data);
const assembled = assemblePayload(solvedMap, result.totalGenerations, result.dataLength);
const recovered = new TextDecoder().decode(assembled);
expect(recovered).toBe('GIF roundtrip test data');
});
});
+67 -154
View File
@@ -1,180 +1,93 @@
/**
* Production code path test: encode worker logic + gif worker logic.
* Production roundtrip test: mimics the production app flow.
*/
import { describe, it, expect } from 'vitest';
import { SenderPacketizer } from '@/core/sender/packetizer';
import { packetize } from '@/core/sender/packetizer';
import { scheduleFrames } from '@/core/sender/scheduler';
import { generateQRMatrix } from '@/core/qr/qr_encode';
import { rasterizeQR } from '@/core/qr/frame_raster';
import { createQRGif } from '@/core/gif/gif_render';
import { parseGif, renderGifFrame } from '@/core/gif/gif_parser';
import { decodeQRFromCanvas } from '@/core/qr/qr_decode';
import { ProfileId, PROFILES } from '@/core/protocol/constants';
import { fragmentManifest } from '@/core/protocol/manifest';
import { encodeGeneration } from '@/core/fec/rlnc_encoder';
import { createPacket, PacketHeader } from '@/core/protocol/packet';
import { PacketType, Flags, PROTOCOL_VERSION } from '@/core/protocol/constants';
import { deflateSync } from 'fflate';
import { parsePacket } from '@/core/protocol/packet';
import { GenerationDecoder } from '@/core/fec/rlnc_decoder';
import { assemblePayload } from '@/core/reconstruct/assemble';
import {
PacketType,
K,
MAX_PAYLOAD_SIZE,
QR_VERSION,
ECC_LEVEL,
FRAME_DELAY,
} from '@/core/protocol/constants';
describe('Production roundtrip', () => {
it('should decode all frames using production code path', async () => {
const text = 'Hello world this is a test of the QR GIF system with a bit more text to fill up some space.';
const originalBytes = new TextEncoder().encode(text);
const profileId = ProfileId.ROBUST;
const profile = PROFILES[profileId];
const compress = true;
describe('Production Roundtrip', () => {
it('should transfer a binary payload via GIF with frame loss', async () => {
const payload = new Uint8Array(500);
crypto.getRandomValues(payload);
// --- Replicate encode.worker.ts logic ---
let preprocessed: Uint8Array;
if (compress) {
preprocessed = deflateSync(originalBytes);
} else {
preprocessed = new Uint8Array(originalBytes);
}
const result = packetize(payload, false, true);
expect(result.isCompressed).toBe(true);
const sessionId = 123456789n; // fixed for determinism
const narrowSessionId = Number(sessionId & BigInt('0xFFFFFFFF'));
const maxPayload = profile.maxPacketPayload;
const K = profile.k;
const R = profile.r;
const frames = scheduleFrames(result.packets, result.totalGenerations, result.sessionId);
const symbols: Uint8Array[] = [];
for (let offset = 0; offset < preprocessed.length; offset += maxPayload) {
const chunk = preprocessed.slice(offset, offset + maxPayload);
if (chunk.length < maxPayload) {
const padded = new Uint8Array(maxPayload);
padded.set(chunk);
symbols.push(padded);
} else {
symbols.push(chunk);
// Build GIF (production path)
const imageFrames: Uint8Array[] = [];
let width = 0;
let height = 0;
for (const frame of frames) {
const matrix = generateQRMatrix(frame, QR_VERSION, ECC_LEVEL);
const imageData = rasterizeQR(matrix, 4);
if (width === 0) {
width = imageData.width;
height = imageData.height;
}
imageFrames.push(new Uint8Array(imageData.data.buffer));
}
const gifBytes = createQRGif(imageFrames, FRAME_DELAY * 10, width, height);
const totalSymbols = symbols.length;
const totalGenerations = Math.max(1, Math.ceil(totalSymbols / K));
const packets: Uint8Array[] = [];
for (let gen = 0; gen < totalGenerations; gen++) {
const startIdx = gen * K;
const genSymbolsCount = Math.min(K, totalSymbols - startIdx);
const isLastGen = gen === totalGenerations - 1;
const genSourceSymbols: Uint8Array[] = [];
for (let i = 0; i < K; i++) {
if (i < genSymbolsCount) {
genSourceSymbols.push(symbols[startIdx + i]!);
} else {
genSourceSymbols.push(new Uint8Array(maxPayload));
}
}
const codedSymbols = encodeGeneration(genSourceSymbols, K, R, narrowSessionId, gen, 0);
for (let i = 0; i < K; i++) {
const cs = codedSymbols[i]!;
const header: PacketHeader = {
protocolVersion: PROTOCOL_VERSION,
packetType: PacketType.DATA_SYSTEMATIC,
flags: isLastGen && i >= genSymbolsCount ? Flags.PAYLOAD_PADDED : 0,
profileId,
sessionId,
generationIndex: gen,
symbolIndex: cs.sourceIndex,
generationK: K,
payloadLength: cs.data.length,
codingSeed: 0,
};
packets.push(createPacket(header, cs.data));
}
for (let j = 0; j < R; j++) {
const cs = codedSymbols[K + j]!;
const header: PacketHeader = {
protocolVersion: PROTOCOL_VERSION,
packetType: PacketType.DATA_CODED,
flags: isLastGen ? Flags.LAST_SYMBOL_IN_GENERATION : 0,
profileId,
sessionId,
generationIndex: gen,
symbolIndex: j,
generationK: K,
payloadLength: cs.data.length,
codingSeed: 0,
};
packets.push(createPacket(header, cs.data));
}
}
// Build manifest
const manifest = {
protocolVersion: PROTOCOL_VERSION,
appVersion: '1.0.0',
sessionId,
originalFilename: 'test.txt',
mimeType: 'text/plain',
contentKind: 'file' as const,
originalSize: originalBytes.length,
preprocessedSize: preprocessed.length,
compressionCodec: 'deflate-raw' as const,
originalSha256: 'a'.repeat(64),
qrProfile: profileId,
packetPayloadSize: maxPayload,
generationK: K,
codedPerGen: R,
totalGenerations,
lastGenRealSize: totalSymbols - (totalGenerations - 1) * K,
gifFrameDelay: profile.frameDelay,
loopParams: 0,
};
const manifestPackets = fragmentManifest(manifest, maxPayload);
const allPackets = [...manifestPackets, ...packets];
console.log('Manifest fragments:', manifestPackets.length);
console.log('Data packets:', packets.length);
console.log('Total frames:', allPackets.length);
// --- Replicate gif.worker.ts logic ---
const qrVersion = profile.qrVersion;
const eccLevel = profile.eccLevel;
const moduleCount = qrVersion * 4 + 17;
const targetPx = 360;
const quietModules = 8;
const totalModules = moduleCount + quietModules;
const scale = Math.max(2, Math.round(targetPx / totalModules));
const frames: Uint8Array[] = [];
let width = 0, height = 0;
for (const packet of allPackets) {
const matrix = generateQRMatrix(packet, qrVersion, eccLevel);
const imageData = rasterizeQR(matrix, scale);
if (width === 0) { width = imageData.width; height = imageData.height; }
frames.push(new Uint8Array(imageData.data.buffer, imageData.data.byteOffset, imageData.data.byteLength));
}
const delayMs = profile.frameDelay * 10;
const gifBytes = createQRGif(frames, delayMs, width, height);
console.log('GIF size:', gifBytes.length);
// --- Parse and decode ---
// Parse GIF (receiver file-upload path)
const gifData = parseGif(gifBytes);
console.log('Parsed frames:', gifData.frames.length);
expect(gifData.frames.length).toBe(allPackets.length);
let decodedCount = 0;
const failures: number[] = [];
// Decode with deterministic frame loss: drop every 5th frame (~20% loss)
const keepIndices = new Set<number>();
for (let i = 0; i < gifData.frames.length; i++) {
if ((i + 1) % 5 !== 0) keepIndices.add(i);
}
const decoder = new GenerationDecoder(K, MAX_PAYLOAD_SIZE, result.sessionId, 0);
const solvedGens = new Set<number>();
for (let i = 0; i < gifData.frames.length; i++) {
if (!keepIndices.has(i)) continue;
const rgba = renderGifFrame(gifData, i);
const imageData = new ImageData(rgba, gifData.width, gifData.height);
const qrResult = decodeQRFromCanvas(imageData);
if (qrResult) {
decodedCount++;
const decodedBytes = decodeQRFromCanvas(imageData);
if (!decodedBytes) continue;
const pkt = parsePacket(decodedBytes);
if (pkt.header.packetType === PacketType.DATA_SYSTEMATIC) {
decoder.addSystematicSymbol(pkt.header.generationIndex, pkt.payload, pkt.header.symbolIndex);
} else {
failures.push(i);
decoder.addCodedSymbol(pkt.header.generationIndex, pkt.payload, pkt.header.symbolIndex);
}
if (decoder.isSolved(pkt.header.generationIndex)) {
solvedGens.add(pkt.header.generationIndex);
}
}
console.log('Decoded:', decodedCount, '/', gifData.frames.length);
console.log('Failures:', failures);
expect(failures.length).toBe(0);
expect(solvedGens.size).toBe(result.totalGenerations);
const solvedMap = new Map<number, Uint8Array[]>();
for (let g = 0; g < result.totalGenerations; g++) {
solvedMap.set(g, decoder.getSourceSymbols(g)!);
}
const { inflateSync } = await import('fflate');
const assembled = assemblePayload(solvedMap, result.totalGenerations, result.dataLength);
const decompressed = inflateSync(assembled);
expect(decompressed).toEqual(payload);
});
});
+83 -147
View File
@@ -1,10 +1,6 @@
/**
* Decode worker receives camera frames, decodes QR codes, parses
* packets, routes to GenerationDecoder, tracks progress, and signals
* when reconstruction is complete.
*
* Maintains state between messages (generation decoders, dedup sets,
* manifest fragments).
* Decode worker receives camera/GIF frames, decodes QR codes, parses
* packets, routes to GenerationDecoder, and signals completion.
*
* @module
*/
@@ -13,36 +9,30 @@ import { inflateSync } from 'fflate';
import { decodeQRFromCanvas } from '@/core/qr/qr_decode';
import { parsePacket } from '@/core/protocol/packet';
import type { Packet } from '@/core/protocol/packet';
import {
PacketType,
PROFILES,
} from '@/core/protocol/constants';
import type { ProfileConfig } from '@/core/protocol/constants';
import { defragmentManifest } from '@/core/protocol/manifest';
import type { ManifestData } from '@/core/protocol/manifest';
import { PacketType, K, MAX_PAYLOAD_SIZE } from '@/core/protocol/constants';
import { GenerationDecoder } from '@/core/fec/rlnc_decoder';
// ─── Session state ───────────────────────────────────────────────────────────
interface SessionState {
sessionKey: string;
manifest: ManifestData | null;
manifestFragments: Uint8Array[];
decoder: GenerationDecoder | null;
sessionId: number;
decoder: GenerationDecoder;
dedup: Set<string>;
receivedPackets: number;
solvedGenerations: Set<number>;
totalGenerations: number;
dataLength: number;
isText: boolean;
isCompressed: boolean;
completed: boolean;
stats: {
framesDecoded: number;
framesWithQR: number;
};
profile: ProfileConfig | null;
/** Set to true once reconstruction is done so late frames are ignored. */
completed: boolean;
}
// Worker-global state (keyed by sessionId.toString())
const sessions = new Map<string, SessionState>();
// Worker-global state (keyed by sessionId)
const sessions = new Map<number, SessionState>();
// ─── Worker handler ──────────────────────────────────────────────────────────
@@ -70,7 +60,7 @@ self.onmessage = (e: MessageEvent) => {
}
if (!imageData) return;
try {
handleFrame(imageData!);
handleFrame(imageData);
} catch (err: any) {
self.postMessage({ type: 'error', message: `Frame error: ${err.message ?? String(err)}` });
}
@@ -81,227 +71,173 @@ self.onmessage = (e: MessageEvent) => {
// ─── Frame handling ──────────────────────────────────────────────────────────
function handleFrame(imageData: ImageData): void {
// 1. Decode QR code from image
const decoded = decodeQRFromCanvas(imageData);
if (!decoded) return; // No QR code found in this frame
if (!decoded) return;
// 2. Decoded is already Uint8Array (raw bytes from jsQR chunks)
const bytes = decoded;
// 3. Parse packet
let packet: Packet;
try {
packet = parsePacket(bytes);
} catch {
return; // Invalid packet, skip silently
return;
}
const h = packet.header;
const sessionKey = h.sessionId.toString();
const sid = h.sessionId;
// 4. Get or create session state
let state = sessions.get(sessionKey);
// Get or create session state
let state = sessions.get(sid);
if (!state) {
state = {
sessionKey,
manifest: null,
manifestFragments: [],
decoder: null,
sessionId: sid,
decoder: new GenerationDecoder(K, MAX_PAYLOAD_SIZE, sid, 0),
dedup: new Set(),
receivedPackets: 0,
solvedGenerations: new Set(),
stats: { framesDecoded: 0, framesWithQR: 0 },
profile: null,
totalGenerations: h.totalGenerations,
dataLength: h.dataLength,
isText: (h.flags & 1) !== 0,
isCompressed: (h.flags & 2) !== 0,
completed: false,
stats: { framesDecoded: 0, framesWithQR: 0 },
};
sessions.set(sessionKey, state);
sessions.set(sid, state);
}
// If this session is already reconstructed, ignore late frames
if (state.completed) return;
// Update metadata from header (in case first packet was incomplete)
state.totalGenerations = h.totalGenerations;
state.dataLength = h.dataLength;
state.isText = (h.flags & 1) !== 0;
state.isCompressed = (h.flags & 2) !== 0;
state.stats.framesDecoded++;
// 5. Dedup: skip already-seen (sessionId:generationIndex:packetType:symbolIndex)
// Include packetType so manifest fragments don't collide with data symbols.
const dedupKey = `${sessionKey}:${h.generationIndex}:${h.packetType}:${h.symbolIndex}`;
// Dedup: sessionId:generationIndex:packetType:symbolIndex
const dedupKey = `${sid}:${h.generationIndex}:${h.packetType}:${h.symbolIndex}`;
if (state.dedup.has(dedupKey)) return;
state.dedup.add(dedupKey);
state.stats.framesWithQR++;
// 6. Route by packet type
if (h.packetType === PacketType.MANIFEST) {
handleManifestPacket(state, bytes);
} else if (
h.packetType === PacketType.DATA_SYSTEMATIC ||
h.packetType === PacketType.DATA_CODED
) {
handleDataPacket(state, packet);
}
// If reconstruction just completed, don't send a trailing progress message
if (state.completed) return;
// 7. Report progress back to main thread
reportProgress(state);
}
// ─── Manifest packet — accumulate fragments, defrag when complete ────────────
function handleManifestPacket(state: SessionState, packetBytes: Uint8Array): void {
state.manifestFragments.push(packetBytes);
if (state.manifest) return; // Already have full manifest
try {
const manifest = defragmentManifest(state.manifestFragments);
state.manifest = manifest;
state.profile = PROFILES[manifest.qrProfile];
// Create GenerationDecoder with manifest parameters
// sessionId is bigint; narrow to 32-bit number for RLNC
const sessionIdNum = Number(manifest.sessionId & BigInt('0xFFFFFFFF'));
state.decoder = new GenerationDecoder(
manifest.generationK,
manifest.packetPayloadSize,
sessionIdNum,
0, // codingSeed — matches encoder default
);
} catch {
// Not all fragments collected yet; that's expected
}
}
// ─── Data packet — feed to generation decoder ────────────────────────────────
function handleDataPacket(state: SessionState, packet: Packet): void {
if (!state.decoder || !state.manifest) return;
const h = packet.header;
// Feed to decoder
const gen = h.generationIndex;
const decoder = state.decoder;
let accepted = false;
if (h.packetType === PacketType.DATA_SYSTEMATIC) {
// Systematic: coefficient vector has a single 1 at sourceIndex
accepted = decoder.addSystematicSymbol(gen, packet.payload, h.symbolIndex);
accepted = state.decoder.addSystematicSymbol(gen, packet.payload, h.symbolIndex);
} else {
// Coded: derive coefficients from codedSymbolIndex (stored in symbolIndex)
accepted = decoder.addCodedSymbol(gen, packet.payload, h.symbolIndex);
accepted = state.decoder.addCodedSymbol(gen, packet.payload, h.symbolIndex);
}
if (accepted) {
state.receivedPackets++;
if (decoder.isSolved(gen)) {
if (state.decoder.isSolved(gen)) {
state.solvedGenerations.add(gen);
// Check if all generations solved
if (state.solvedGenerations.size >= state.manifest.totalGenerations) {
if (state.solvedGenerations.size >= state.totalGenerations) {
reconstructData(state);
if (state.completed) return;
}
}
}
reportProgress(state);
}
// ─── Reconstruct original data from all source symbols ──────────────────────
function reconstructData(state: SessionState): void {
const manifest = state.manifest!;
const decoder = state.decoder!;
const decoder = state.decoder;
// Accumulate preprocessed data from each generation's source symbols
// Collect preprocessed data from each generation's source symbols
const preprocessedParts: Uint8Array[] = [];
const payloadSize = manifest.packetPayloadSize;
const k = manifest.generationK;
for (let gen = 0; gen < manifest.totalGenerations; gen++) {
for (let gen = 0; gen < state.totalGenerations; gen++) {
const symbols = decoder.getSourceSymbols(gen);
if (!symbols) {
self.postMessage({
type: 'error',
sessionId: state.sessionKey,
sessionId: state.sessionId,
message: `Generation ${gen} not solved — reconstruction aborted`,
});
return;
}
// Only take the real symbols (not padding)
const isLastGen = gen === manifest.totalGenerations - 1;
const realCount = isLastGen ? manifest.lastGenRealSize : k;
for (let i = 0; i < realCount; i++) {
const sym = symbols[i]!;
preprocessedParts.push(new Uint8Array(sym)); // copy
for (const sym of symbols) {
preprocessedParts.push(new Uint8Array(sym));
}
}
// Concatenate parts, respecting exact preprocessed size
const totalSize = manifest.preprocessedSize;
// Concatenate and trim to exact dataLength
const totalSize = preprocessedParts.reduce((s, p) => s + p.length, 0);
const combined = new Uint8Array(totalSize);
let offset = 0;
for (const part of preprocessedParts) {
const remaining = totalSize - offset;
if (remaining <= 0) break;
const len = Math.min(part.length, remaining);
combined.set(part.subarray(0, len), offset);
offset += len;
combined.set(part, offset);
offset += part.length;
}
const trimmed = combined.slice(0, state.dataLength);
// Handle compression
// Decompress if needed
let finalData: Uint8Array;
if (manifest.compressionCodec === 'deflate-raw') {
if (state.isCompressed) {
try {
finalData = inflateSync(combined);
finalData = inflateSync(trimmed);
} catch (err) {
self.postMessage({
type: 'error',
sessionId: state.sessionKey,
sessionId: state.sessionId,
message: 'Decompression failed — data may be corrupted',
});
return;
}
} else {
finalData = combined;
finalData = trimmed;
}
// Determine output filename
const filename = manifest.originalFilename || `recovered-${state.sessionKey.slice(0, 8)}`;
// Signal completion — use transferable
self.postMessage(
{
if (state.isText) {
const text = new TextDecoder().decode(finalData);
self.postMessage({
type: 'complete',
sessionId: state.sessionKey,
data: finalData.buffer,
filename,
mime: manifest.mimeType,
},
{ transfer: [finalData.buffer as ArrayBuffer] },
);
sessionId: state.sessionId,
isText: true,
text,
});
} else {
self.postMessage(
{
type: 'complete',
sessionId: state.sessionId,
isText: false,
data: finalData.buffer,
filename: `recovered-${state.sessionId.toString(16).padStart(8, '0')}`,
mime: 'application/octet-stream',
},
{ transfer: [finalData.buffer as ArrayBuffer] },
);
}
// Mark session completed so late frames are silently ignored
state.completed = true;
}
// ─── Progress reporting ──────────────────────────────────────────────────────
function reportProgress(state: SessionState): void {
const totalGens = state.manifest?.totalGenerations ?? 0;
const totalGens = state.totalGenerations;
const solvedGens = state.solvedGenerations.size;
self.postMessage({
type: 'progress',
sessionId: state.sessionKey,
sessionId: state.sessionId,
framesDecoded: state.stats.framesDecoded,
framesWithQR: state.stats.framesWithQR,
receivedPackets: state.receivedPackets,
solvedGenerations: solvedGens,
totalGenerations: totalGens,
status: state.manifest
? solvedGens >= totalGens
? 'Reconstructing…'
: `Receiving (${solvedGens}/${totalGens} gens)`
: 'Receiving manifest…',
status: totalGens > 0
? `Receiving (${solvedGens}/${totalGens} gens)`
: 'Receiving…',
});
}
+18 -220
View File
@@ -1,47 +1,30 @@
/**
* Encode worker receives raw data, runs full sender pipeline:
* compress hash packetize RLNC encode manifest.
* Encode worker receives raw data, compresses, packetizes, schedules.
*
* @module
*/
import { deflateSync } from 'fflate';
import {
ProfileId,
PROFILES,
PROTOCOL_VERSION,
createSessionId,
PacketType,
Flags,
} from '@/core/protocol/constants';
import type { ProfileConfig } from '@/core/protocol/constants';
import { PacketHeader, createPacket } from '@/core/protocol/packet';
import type { ManifestData } from '@/core/protocol/manifest';
import { fragmentManifest } from '@/core/protocol/manifest';
import { encodeGeneration } from '@/core/fec/rlnc_encoder';
import { packetize } from '@/core/sender/packetizer';
import { scheduleFrames } from '@/core/sender/scheduler';
// ─── Types ───────────────────────────────────────────────────────────────────
interface EncodeInput {
type: 'encode';
data: ArrayBuffer;
profileId: ProfileId;
filename: string;
mime: string;
isText: boolean;
compress: boolean;
}
interface EncodeOutput {
type: 'encoded';
packets: Uint8Array[];
manifest: ManifestData;
sessionId: number;
totalGenerations: number;
stats: {
originalSize: number;
preprocessedSize: number;
frameCount: number;
estimatedGifBytes: number;
totalGenerations: number;
packetsPerGen: number;
};
}
@@ -50,7 +33,7 @@ interface ErrorOutput {
message: string;
}
// ─── Worker handler ──────────────────────────────────────────────────────────
// ─── Worker handler ─────────────────────────────────────────────────────────────────u2500
self.onmessage = (e: MessageEvent<EncodeInput>) => {
const msg = e.data;
@@ -58,214 +41,29 @@ self.onmessage = (e: MessageEvent<EncodeInput>) => {
try {
const result = handleEncode(msg);
// Collect transferable buffers (avoid SharedArrayBuffer issues by filtering)
const transfer: ArrayBufferLike[] = result.packets
.map(p => p.buffer as ArrayBuffer)
.map((p) => p.buffer as ArrayBuffer)
.filter((b): b is ArrayBuffer => b instanceof ArrayBuffer && b.byteLength <= 1024 * 1024);
self.postMessage(result, transfer.length > 0 ? { transfer: transfer } : undefined);
self.postMessage(result, transfer.length > 0 ? { transfer } : undefined);
} catch (err: any) {
self.postMessage({ type: 'error', message: err.message ?? String(err) } satisfies ErrorOutput);
}
};
function handleEncode(input: EncodeInput): EncodeOutput {
const { data, profileId, filename, mime, compress } = input;
const originalBytes = new Uint8Array(data);
const originalSize = originalBytes.length;
const profile: ProfileConfig = PROFILES[profileId];
// ── 1. Hash original data ──────────────────────────────────────────────
const originalSha256 = sha256Hex(originalBytes);
// ── 2. Optional compression ─────────────────────────────────────────────
let preprocessed: Uint8Array;
let compressionCodec: 'none' | 'deflate-raw';
if (compress) {
preprocessed = deflateSync(originalBytes);
compressionCodec = 'deflate-raw';
} else {
preprocessed = new Uint8Array(originalBytes);
compressionCodec = 'none';
}
const preprocessedSize = preprocessed.length;
// ── 3. Create session ───────────────────────────────────────────────────
const sessionId = createSessionId();
const narrowSessionId = Number(sessionId & BigInt('0xFFFFFFFF'));
const maxPayload = profile.maxPacketPayload;
const K = profile.k;
const R = profile.r;
const codingSeed = 0;
// ── 4. Split preprocessed data into symbols ─────────────────────────────
const symbols: Uint8Array[] = [];
for (let offset = 0; offset < preprocessedSize; offset += maxPayload) {
const chunk = preprocessed.slice(offset, offset + maxPayload);
// Pad the last chunk to maxPayload bytes for uniform symbol length
if (chunk.length < maxPayload) {
const padded = new Uint8Array(maxPayload);
padded.set(chunk);
symbols.push(padded);
} else {
symbols.push(chunk);
}
}
const totalSymbols = symbols.length;
const totalGenerations = Math.max(1, Math.ceil(totalSymbols / K));
// ── 5. Encode generations & create packets ──────────────────────────────
const packets: Uint8Array[] = [];
for (let gen = 0; gen < totalGenerations; gen++) {
const startIdx = gen * K;
const genSymbolsCount = Math.min(K, totalSymbols - startIdx);
const isLastGen = gen === totalGenerations - 1;
// Collect this generation's source symbols (pad to exactly K)
const genSourceSymbols: Uint8Array[] = [];
for (let i = 0; i < K; i++) {
if (i < genSymbolsCount) {
genSourceSymbols.push(symbols[startIdx + i]!);
} else {
// Padding symbol: zero-filled of same length
genSourceSymbols.push(new Uint8Array(maxPayload));
}
}
// Encode generation: K systematic + R coded
const codedSymbols = encodeGeneration(
genSourceSymbols,
K,
R,
narrowSessionId,
gen,
codingSeed,
);
// Create packets for systematic symbols (first K outputs)
for (let i = 0; i < K; i++) {
const cs = codedSymbols[i]!;
const header: PacketHeader = {
protocolVersion: PROTOCOL_VERSION,
packetType: PacketType.DATA_SYSTEMATIC,
flags: isLastGen && i >= genSymbolsCount ? Flags.PAYLOAD_PADDED : 0,
profileId,
sessionId,
generationIndex: gen,
symbolIndex: cs.sourceIndex,
generationK: K,
payloadLength: cs.data.length,
codingSeed: 0,
};
packets.push(createPacket(header, cs.data));
}
// Create packets for coded symbols (last R outputs)
for (let j = 0; j < R; j++) {
const cs = codedSymbols[K + j]!;
const header: PacketHeader = {
protocolVersion: PROTOCOL_VERSION,
packetType: PacketType.DATA_CODED,
flags: isLastGen ? Flags.LAST_SYMBOL_IN_GENERATION : 0,
profileId,
sessionId,
generationIndex: gen,
symbolIndex: j,
generationK: K,
payloadLength: cs.data.length,
codingSeed,
};
packets.push(createPacket(header, cs.data));
}
}
// ── 6. Compute frame delay from profile ─────────────────────────────────
const frameDelay = profile.frameDelay;
// ── 7. Build manifest ───────────────────────────────────────────────────
const manifest: ManifestData = {
protocolVersion: PROTOCOL_VERSION,
appVersion: '1.0.0',
sessionId,
originalFilename: filename,
mimeType: mime,
contentKind: filename ? 'file' : 'text',
originalSize,
preprocessedSize,
compressionCodec,
originalSha256,
qrProfile: profileId,
packetPayloadSize: maxPayload,
generationK: K,
codedPerGen: R,
totalGenerations,
lastGenRealSize: totalSymbols - (totalGenerations - 1) * K,
gifFrameDelay: frameDelay,
loopParams: 0,
};
// ── 8. Fragment manifest into packets ───────────────────────────────────
const manifestPackets = fragmentManifest(manifest, maxPayload);
// ── 9. Assemble final packet order: manifest first, then data ───────────
const allPackets = [...manifestPackets, ...packets];
// ── 10. Compute stats ───────────────────────────────────────────────────
const frameCount = allPackets.length;
const moduleCount = getModuleCount(profile.qrVersion);
const px = getRasterPixels(moduleCount, 3);
const rawRgbaBytes = px * px * 4 * frameCount;
const estimatedGifBytes = Math.round(rawRgbaBytes * 0.15) + 150 * frameCount + 32;
const originalBytes = new Uint8Array(input.data);
const result = packetize(originalBytes, input.isText, input.compress);
const frames = scheduleFrames(result.packets, result.totalGenerations, result.sessionId);
return {
type: 'encoded',
packets: allPackets,
manifest,
packets: frames,
sessionId: result.sessionId,
totalGenerations: result.totalGenerations,
stats: {
originalSize,
preprocessedSize,
frameCount,
estimatedGifBytes,
totalGenerations,
packetsPerGen: K + R,
originalSize: originalBytes.length,
preprocessedSize: result.dataLength,
frameCount: frames.length,
},
};
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Compute SHA-256 hex digest.
* Uses a simple FNV-1a hash as synchronous fallback (not crypto-secure
* but sufficient for dedup in this transfer context).
*/
function sha256Hex(data: Uint8Array): string {
let hash = 0x811c9dc5;
for (let i = 0; i < data.length; i++) {
hash ^= data[i]!;
hash = Math.imul(hash, 0x01000193);
}
// Expand to 32 bytes for SHA-256-compatible length
const hashArray = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
hashArray[i] = (hash >> ((i % 4) * 8)) & 0xff;
hash = Math.imul(hash ^ (i + 1), 0x01000193);
}
return Array.from(hashArray)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/** Approximate QR module count for a given version. */
function getModuleCount(version: number): number {
return version * 4 + 17;
}
/** Pixel size of a rasterized QR with given module count and scale,
* including 4-module quiet zone on each side. */
function getRasterPixels(moduleCount: number, scale: number): number {
return (moduleCount + 8) * scale;
}
+9 -21
View File
@@ -8,16 +8,13 @@
import { generateQRMatrix } from '@/core/qr/qr_encode';
import { rasterizeQR } from '@/core/qr/frame_raster';
import { createQRGif } from '@/core/gif/gif_render';
import type { ProfileConfig } from '@/core/protocol/constants';
import type { ManifestData } from '@/core/protocol/manifest';
import { QR_VERSION, ECC_LEVEL, FRAME_DELAY } from '@/core/protocol/constants';
// ─── Types ───────────────────────────────────────────────────────────────────
interface GenerateInput {
type: 'generate';
packets: Uint8Array[];
manifest: ManifestData;
profile: ProfileConfig;
}
interface GifOutput {
@@ -33,7 +30,7 @@ interface ErrorOutput {
message: string;
}
// ─── Worker handler ──────────────────────────────────────────────────────────
// ─── Worker handler ─────────────────────────────────────────────────────────────────u2500
self.onmessage = (e: MessageEvent<GenerateInput>) => {
const msg = e.data;
@@ -41,7 +38,6 @@ self.onmessage = (e: MessageEvent<GenerateInput>) => {
try {
const result = handleGenerate(msg);
// Transfer the GIF buffer to avoid extra copy
self.postMessage(result, [result.gifData]);
} catch (err: any) {
self.postMessage({ type: 'error', message: err.message ?? String(err) } satisfies ErrorOutput);
@@ -49,11 +45,9 @@ self.onmessage = (e: MessageEvent<GenerateInput>) => {
};
function handleGenerate(input: GenerateInput): GifOutput {
const { packets, manifest, profile } = input;
const { qrVersion, eccLevel, frameDelay } = profile;
const { packets } = input;
// QR module count for this version
const moduleCount = qrVersion * 4 + 17;
const moduleCount = QR_VERSION * 4 + 17;
// Determine optimal scale: aim for ~300-400 px width
const targetPx = 360;
@@ -61,35 +55,29 @@ function handleGenerate(input: GenerateInput): GifOutput {
const totalModules = moduleCount + quietModules;
const scale = Math.max(2, Math.round(targetPx / totalModules));
// ── Generate QR matrix for each packet ──────────────────────────────────
// ── Generate QR matrix for each packet ────────────────────────────────────
const frames: Uint8Array[] = [];
let width = 0;
let height = 0;
for (let i = 0; i < packets.length; i++) {
const packet = packets[i]!;
// Generate QR code matrix from raw packet bytes
const matrix = generateQRMatrix(packet, qrVersion, eccLevel);
// Rasterize to RGBA pixel data
const matrix = generateQRMatrix(packet, QR_VERSION, ECC_LEVEL);
const imageData = rasterizeQR(matrix, scale);
if (i === 0) {
width = imageData.width;
height = imageData.height;
}
frames.push(new Uint8Array(imageData.data.buffer));
}
// ── Create animated GIF ────────────────────────────────────────────────
// frameDelay from profile is in centiseconds; gifenc expects milliseconds
const delayMs = frameDelay * 10; // cs → ms
// ── Create animated GIF ────────────────────────────────────────────────
const delayMs = FRAME_DELAY * 10; // cs → ms
const gifBytes = createQRGif(frames, delayMs, width, height);
return {
type: 'gifReady',
gifData: gifBytes.buffer.slice(gifBytes.byteOffset, gifBytes.byteOffset + gifBytes.byteLength),
gifData: gifBytes.buffer.slice(gifBytes.byteOffset, gifBytes.byteOffset + gifBytes.byteLength) as ArrayBuffer,
width,
height,
frameCount: frames.length,