mirror of
https://github.com/infrost/RaptorQR.git
synced 2026-09-05 09:27:50 +08:00
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:
+73
-50
@@ -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
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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 0–27). */
|
||||
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 (1–40). */
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 0–27 + 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 0–17 + 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 (0–27) + 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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user