diff --git a/src/app/routes/receiver.tsx b/src/app/routes/receiver.tsx index 78d357b..b11290d 100644 --- a/src/app/routes/receiver.tsx +++ b/src/app/routes/receiver.tsx @@ -7,15 +7,6 @@ import { parseGif, renderGifFrame } from '@/core/gif/gif_parser'; // ─── Types ─────────────────────────────────────────────────────────────────── -interface SessionInfo { - sessionId: string; - progress: number; - solvedGenerations: number; - totalGenerations: number; - framesDecoded: number; - status: 'receiving' | 'complete' | 'error'; -} - interface ReceivedFile { data: ArrayBuffer; filename: string; @@ -77,48 +68,21 @@ const S = { display: 'block', marginTop: 8, } as CSSProps, - progressOuter: { - width: '100%', - height: 8, - background: '#30363d', - borderRadius: 4, - overflow: 'hidden', - marginTop: 8, - } as CSSProps, - progressInner: (pct: number): CSSProps => ({ - width: `${Math.min(100, Math.max(0, pct))}%`, - height: '100%', - background: pct >= 100 ? '#3fb950' : '#58a6ff', - borderRadius: 4, - transition: 'width 0.3s ease', - }), - table: { - width: '100%', - borderCollapse: 'collapse' as const, - fontSize: 13, - marginTop: 8, - } as CSSProps, - th: { - textAlign: 'left' as const, - padding: '8px 10px', - borderBottom: '1px solid #30363d', - color: '#8b949e', - fontWeight: 600, - }, - td: { - padding: '8px 10px', - borderBottom: '1px solid #21262d', - color: '#c9d1d9', - }, - statusBadge: (status: string): CSSProps => ({ - display: 'inline-block', - padding: '2px 8px', - borderRadius: 10, + statsBar: { + display: 'flex', + gap: 16, + alignItems: 'center', fontSize: 12, + color: '#8b949e', + marginTop: 6, + flexWrap: 'wrap' as const, + } as CSSProps, + statValue: { + color: '#c9d1d9', fontWeight: 600, - background: status === 'complete' ? '#1b3a1b' : status === 'error' ? '#3d1a1a' : '#1f2937', - color: status === 'complete' ? '#3fb950' : status === 'error' ? '#f85149' : '#8b949e', - }), + fontFamily: 'monospace', + fontSize: 13, + } as CSSProps, warn: { background: '#3d2600', border: '1px solid #bb8009', @@ -186,14 +150,12 @@ export function ReceiverPage() { const [inputMode, setInputMode] = useState('camera'); const [scanning, setScanning] = useState(false); const [status, setStatus] = useState(''); - const [progress, setProgress] = useState(0); const [totalFrames, setTotalFrames] = useState(0); const [framesWithQR, setFramesWithQR] = useState(0); const [acceptedPackets, setAcceptedPackets] = useState(0); const [neededPackets, setNeededPackets] = useState(0); const [solvedGens, setSolvedGens] = useState(0); const [totalGens, setTotalGens] = useState(0); - const [sessions, setSessions] = useState([]); const [receivedFile, setReceivedFile] = useState(null); const [receivedText, setReceivedText] = useState(''); const [error, setError] = useState(''); @@ -217,40 +179,7 @@ export function ReceiverPage() { setNeededPackets(msg.neededPackets ?? 0); setSolvedGens(msg.solvedGenerations); setTotalGens(msg.totalGenerations); - setProgress(msg.totalGenerations > 0 ? msg.solvedGenerations / msg.totalGenerations : 0); setStatus(msg.status); - - if (msg.sessionId !== undefined) { - const sid = String(msg.sessionId); - setSessions((prev) => { - const existing = prev.find((s) => s.sessionId === sid); - if (existing) { - return prev.map((s) => - s.sessionId === sid - ? { - ...s, - progress: msg.totalGenerations > 0 ? msg.solvedGenerations / msg.totalGenerations : 0, - solvedGenerations: msg.solvedGenerations, - totalGenerations: msg.totalGenerations, - framesDecoded: msg.totalFrames ?? 0, - status: 'receiving' as const, - } - : s, - ); - } - return [ - ...prev, - { - sessionId: sid, - progress: 0, - solvedGenerations: 0, - totalGenerations: msg.totalGenerations, - framesDecoded: 0, - status: 'receiving' as const, - } as SessionInfo, - ]; - }); - } break; } case 'complete': { @@ -266,21 +195,10 @@ export function ReceiverPage() { setReceivedText(''); } setStatus('Complete ✓'); - setProgress(1); - setSessions((prev) => - prev.map((s) => - s.sessionId === String(msg.sessionId) ? { ...s, status: 'complete' as const, progress: 1 } : s, - ), - ); break; } case 'error': { setError(msg.message); - setSessions((prev) => - prev.map((s) => - s.sessionId === String(msg.sessionId) ? { ...s, status: 'error' as const } : s, - ), - ); break; } } @@ -319,8 +237,6 @@ export function ReceiverPage() { setError(''); setReceivedFile(null); setReceivedText(''); - setSessions([]); - setProgress(0); setTotalFrames(0); setFramesWithQR(0); setAcceptedPackets(0); @@ -341,7 +257,6 @@ export function ReceiverPage() { const capabilities = track.getCapabilities() as any; if (capabilities?.zoom) { setHasZoomSupport(true); - // Apply an initial modest zoom if supported const idealZoom = Math.min(2, capabilities.zoom.max ?? 2); try { await track.applyConstraints({ advanced: [{ zoom: idealZoom }] } as any); @@ -392,8 +307,6 @@ export function ReceiverPage() { setError(''); setReceivedFile(null); setReceivedText(''); - setSessions([]); - setProgress(0); setTotalFrames(0); setFramesWithQR(0); setAcceptedPackets(0); @@ -583,6 +496,28 @@ export function ReceiverPage() {
+ + {/* Inline stats above controls */} + {scanning && ( +
+ + scanned {totalFrames} + + + useful {acceptedPackets} + + + need {neededPackets} + + · + + gen {solvedGens} of {totalGens} + + · + {status || 'Working…'} +
+ )} +
{!scanning ? (
)} - {/* ── Status + progress ────────────────────────────────────────────────── */} -
-
Status
-
- - Status: {status || 'Idle'} - - - Generations: {solvedGens}/{totalGens} - -
- {totalGens > 0 && ( -
-
-
- )} - {scanning && ( -
- {' '} - {status || 'Working…'} -
- )} -
- - {/* ── Frame statistics ───────────────────────────────────────────────── */} - {scanning && ( -
-
Frame Statistics
-
-
-
{totalFrames}
-
Scanned
-
-
-
{acceptedPackets}
-
Useful (linearly independent)
-
-
-
{neededPackets}
-
Minimal needed
-
-
-

- Scanned = total frames with valid QR codes seen.  - Useful = linearly independent symbols accepted by the decoder.  - Minimal needed = K × totalGenerations ({16} symbols per generation). -

-
- )} - - {/* ── Sessions table ─────────────────────────────────────────────────────── */} - {sessions.length > 0 && ( -
-
Sessions
- - - - - - - - - - - {sessions.map((s) => ( - - - - - - - ))} - -
Session IDProgressGenerationsStatus
{s.sessionId} -
-
-
- - {Math.round(s.progress * 100)}% - -
- {s.solvedGenerations}/{s.totalGenerations} - - {s.status} -
-
- )} - {/* ── Received text ───────────────────────────────────────────────────── */} {receivedText && (
diff --git a/src/app/routes/sender.tsx b/src/app/routes/sender.tsx index 20df52f..208eeb9 100644 --- a/src/app/routes/sender.tsx +++ b/src/app/routes/sender.tsx @@ -197,7 +197,14 @@ export function SenderPage() { }; encodeWorker.onerror = (err) => { clearTimeout(timeout); reject(err); }; encodeWorker.postMessage( - { type: 'encode', data, isText, compress }, + { + type: 'encode', + data, + isText, + compress, + filename: mode === 'file' ? file?.name : undefined, + mimeType: mode === 'file' ? file?.type : undefined, + }, [data], ); }); diff --git a/src/core/sender/packetizer.ts b/src/core/sender/packetizer.ts index 8498a52..9823747 100644 --- a/src/core/sender/packetizer.ts +++ b/src/core/sender/packetizer.ts @@ -2,11 +2,12 @@ * Sender-side packetizer — single profile, no manifest. * * 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 + * 1. Optional metadata wrapping (filename + mime for files) + * 2. Optional compression (deflate-raw) + * 3. Split preprocessed data into 191-byte symbols + * 4. Group into generations of K=16 + * 5. RLNC encode each generation (16 systematic + 8 coded) + * 6. Build transport packets with metadata in every header * * @module */ @@ -35,7 +36,7 @@ export interface PacketizerResult { isCompressed: boolean; } -// ─── Packetizer ────────────────────────────────────────────────────────────────── +// ─── Packetizer ─────────────────────────────────────────────────────────────────── /** * Encode raw data into transport packets. @@ -43,28 +44,52 @@ export interface PacketizerResult { * @param data Raw bytes to transmit * @param isText Whether the payload is plain text * @param compress Whether to apply deflate-raw compression + * @param filename Optional original filename (for file downloads) + * @param mimeType Optional MIME type (for file downloads) * @returns PacketizerResult containing all packets and metadata */ export function packetize( data: Uint8Array, isText: boolean, compress: boolean, + filename?: string, + mimeType?: string, ): PacketizerResult { - // 1. Optional compression + // 1. Optional metadata wrapping for files + let wrapped: Uint8Array; + if (!isText && filename) { + const nameBytes = new TextEncoder().encode(filename); + const mimeBytes = new TextEncoder().encode(mimeType || 'application/octet-stream'); + const nameLen = Math.min(nameBytes.length, 255); + const mimeLen = Math.min(mimeBytes.length, 255); + wrapped = new Uint8Array(2 + nameLen + mimeLen + data.length); + let off = 0; + wrapped[off++] = nameLen; + wrapped.set(nameBytes.slice(0, nameLen), off); + off += nameLen; + wrapped[off++] = mimeLen; + wrapped.set(mimeBytes.slice(0, mimeLen), off); + off += mimeLen; + wrapped.set(data, off); + } else { + wrapped = new Uint8Array(data); + } + + // 2. Optional compression let preprocessed: Uint8Array; let isCompressed: boolean; - if (compress && data.length > 64) { - preprocessed = deflateSync(data); + if (compress && wrapped.length > 64) { + preprocessed = deflateSync(wrapped); isCompressed = true; } else { - preprocessed = new Uint8Array(data); + preprocessed = new Uint8Array(wrapped); isCompressed = false; } const dataLength = preprocessed.length; - // 2. Split into fixed-size symbols + // 3. 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); @@ -82,7 +107,7 @@ export function packetize( const sessionId = createSessionId(); const codingSeed = 0; - // 3. Encode generations and build packets + // 4. Encode generations and build packets const packets: Uint8Array[] = []; for (let gen = 0; gen < totalGenerations; gen++) { diff --git a/src/workers/decode.worker.ts b/src/workers/decode.worker.ts index 44347a5..7498cf1 100644 --- a/src/workers/decode.worker.ts +++ b/src/workers/decode.worker.ts @@ -12,9 +12,9 @@ import type { Packet } from '@/core/protocol/packet'; import { PacketType, K, MAX_PAYLOAD_SIZE } from '@/core/protocol/constants'; import { GenerationDecoder } from '@/core/fec/rlnc_decoder'; -// ─── Session state ─────────────────────────────────────────────────────────── +// ─── State ─────────────────────────────────────────────────────────────────── -interface SessionState { +interface DecodeState { sessionId: number; decoder: GenerationDecoder; dedup: Set; @@ -26,14 +26,13 @@ interface SessionState { isCompressed: boolean; completed: boolean; stats: { - totalFrames: number; // every frame received by worker - framesWithQR: number; // frames where QR was found - acceptedPackets: number; // linearly independent symbols accepted + totalFrames: number; + framesWithQR: number; + acceptedPackets: number; }; } -// Worker-global state (keyed by sessionId) -const sessions = new Map(); +let current: DecodeState | null = null; // ─── Worker handler ────────────────────────────────────────────────────────── @@ -41,7 +40,7 @@ self.onmessage = (e: MessageEvent) => { const msg = e.data; if (msg.type === 'reset') { - sessions.clear(); + current = null; return; } @@ -72,25 +71,12 @@ self.onmessage = (e: MessageEvent) => { // ─── Frame handling ────────────────────────────────────────────────────────── function handleFrame(imageData: ImageData): void { - let state: SessionState | undefined; - const decoded = decodeQRFromCanvas(imageData, { inversionAttempts: 'attemptBoth' }); - - // If we already have a session, count this frame - // (we don't know sessionId until we parse, so we defer counting) - // Instead: count total frames per-session after first packet seen - - if (!decoded) { - // We can't count this frame against any session since we don't know the sessionId. - // For camera mode this is fine — frames without QR are not attributed. - return; - } - - const bytes = decoded; + if (!decoded) return; let packet: Packet; try { - packet = parsePacket(bytes); + packet = parsePacket(decoded); } catch { return; } @@ -98,10 +84,9 @@ function handleFrame(imageData: ImageData): void { const h = packet.header; const sessionId = h.sessionId; - // Get or create session state - state = sessions.get(sessionId); - if (!state) { - state = { + // Start fresh if this is a new session + if (!current || current.sessionId !== sessionId) { + current = { sessionId, decoder: new GenerationDecoder(K, MAX_PAYLOAD_SIZE, sessionId, 0), dedup: new Set(), @@ -114,55 +99,57 @@ function handleFrame(imageData: ImageData): void { completed: false, stats: { totalFrames: 0, framesWithQR: 0, acceptedPackets: 0 }, }; - sessions.set(sessionId, state); } - if (state.completed) return; + if (current.completed) return; // Update metadata from header - state.totalGenerations = h.totalGenerations; - state.dataLength = h.dataLength; - state.isText = (h.flags & 1) !== 0; - state.isCompressed = (h.flags & 2) !== 0; + current.totalGenerations = h.totalGenerations; + current.dataLength = h.dataLength; + current.isText = (h.flags & 1) !== 0; + current.isCompressed = (h.flags & 2) !== 0; - state.stats.totalFrames++; + current.stats.totalFrames++; - // Dedup: sessionId:generationIndex:packetType:symbolIndex - const dedupKey = `${sessionId}:${h.generationIndex}:${h.packetType}:${h.symbolIndex}`; - if (state.dedup.has(dedupKey)) return; - state.dedup.add(dedupKey); - state.stats.framesWithQR++; + // Dedup: generationIndex:packetType:symbolIndex + const dedupKey = `${h.generationIndex}:${h.packetType}:${h.symbolIndex}`; + if (current.dedup.has(dedupKey)) return; + current.dedup.add(dedupKey); + current.stats.framesWithQR++; // Feed to decoder const gen = h.generationIndex; let accepted = false; if (h.packetType === PacketType.DATA_SYSTEMATIC) { - accepted = state.decoder.addSystematicSymbol(gen, packet.payload, h.symbolIndex); + accepted = current.decoder.addSystematicSymbol(gen, packet.payload, h.symbolIndex); } else { - accepted = state.decoder.addCodedSymbol(gen, packet.payload, h.symbolIndex); + accepted = current.decoder.addCodedSymbol(gen, packet.payload, h.symbolIndex); } if (accepted) { - state.stats.acceptedPackets++; - state.receivedPackets++; + current.stats.acceptedPackets++; + current.receivedPackets++; - if (state.decoder.isSolved(gen)) { - state.solvedGenerations.add(gen); + if (current.decoder.isSolved(gen)) { + current.solvedGenerations.add(gen); - if (state.solvedGenerations.size >= state.totalGenerations) { - reconstructData(state); - if (state.completed) return; + if (current.solvedGenerations.size >= current.totalGenerations) { + reconstructData(current); + if (current.completed) { + reportProgress(current); + return; + } } } } - reportProgress(state); + reportProgress(current); } // ─── Reconstruct original data from all source symbols ────────────────────── -function reconstructData(state: SessionState): void { +function reconstructData(state: DecodeState): void { const decoder = state.decoder; // Collect preprocessed data from each generation's source symbols @@ -173,7 +160,6 @@ function reconstructData(state: SessionState): void { if (!symbols) { self.postMessage({ type: 'error', - sessionId: state.sessionId, message: `Generation ${gen} not solved — reconstruction aborted`, }); return; @@ -201,7 +187,6 @@ function reconstructData(state: SessionState): void { } catch (err) { self.postMessage({ type: 'error', - sessionId: state.sessionId, message: 'Decompression failed — data may be corrupted', }); return; @@ -210,11 +195,33 @@ function reconstructData(state: SessionState): void { finalData = trimmed; } + // Parse optional filename/mime metadata for file mode + let filename = ''; + let mime = 'application/octet-stream'; + + if (!state.isText) { + try { + if (finalData.length >= 2) { + const filenameLen = finalData[0]!; + if (finalData.length >= 2 + filenameLen) { + const mimeLen = finalData[1 + filenameLen]!; + const metaEnd = 2 + filenameLen + mimeLen; + if (finalData.length >= metaEnd) { + filename = new TextDecoder().decode(finalData.slice(1, 1 + filenameLen)); + mime = new TextDecoder().decode(finalData.slice(2 + filenameLen, metaEnd)); + finalData = finalData.slice(metaEnd); + } + } + } + } catch { + // If metadata parsing fails, treat everything as raw data + } + } + if (state.isText) { const text = new TextDecoder().decode(finalData); self.postMessage({ type: 'complete', - sessionId: state.sessionId, isText: true, text, }); @@ -222,11 +229,10 @@ function reconstructData(state: SessionState): void { 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', + filename: filename || `recovered-${state.sessionId.toString(16).padStart(8, '0')}`, + mime: mime || 'application/octet-stream', }, { transfer: [finalData.buffer as ArrayBuffer] }, ); @@ -237,14 +243,13 @@ function reconstructData(state: SessionState): void { // ─── Progress reporting ────────────────────────────────────────────────────── -function reportProgress(state: SessionState): void { +function reportProgress(state: DecodeState): void { const totalGens = state.totalGenerations; const solvedGens = state.solvedGenerations.size; const needed = totalGens > 0 ? K * totalGens : 0; self.postMessage({ type: 'progress', - sessionId: state.sessionId, totalFrames: state.stats.totalFrames, framesWithQR: state.stats.framesWithQR, acceptedPackets: state.stats.acceptedPackets, diff --git a/src/workers/encode.worker.ts b/src/workers/encode.worker.ts index afb20c4..a3d3fe3 100644 --- a/src/workers/encode.worker.ts +++ b/src/workers/encode.worker.ts @@ -14,6 +14,8 @@ interface EncodeInput { data: ArrayBuffer; isText: boolean; compress: boolean; + filename?: string; + mimeType?: string; } interface EncodeOutput { @@ -33,7 +35,7 @@ interface ErrorOutput { message: string; } -// ─── Worker handler ─────────────────────────────────────────────────────────────────u2500 +// ─── Worker handler ─────────────────────────────────────────────────────────────────── self.onmessage = (e: MessageEvent) => { const msg = e.data; @@ -52,7 +54,7 @@ self.onmessage = (e: MessageEvent) => { function handleEncode(input: EncodeInput): EncodeOutput { const originalBytes = new Uint8Array(input.data); - const result = packetize(originalBytes, input.isText, input.compress); + const result = packetize(originalBytes, input.isText, input.compress, input.filename, input.mimeType); const frames = scheduleFrames(result.packets, result.totalGenerations, result.sessionId); return {