feat(receiver): remove sessions, add filename/mime, fix gen counter, compact UI

- Removed session tracking entirely; single global decode state
- Receiver UI: no sessions table, no big progress bar
- Inline stats above video: scanned / useful / need  ·  gen X of Y
- Sender passes filename and mimeType to encode worker
- Packetizer wraps file payloads with [filenameLen][name][mimeLen][mime] header
- Decode worker parses metadata and uses it for download name/type
- Fixed generation counter bug: final progress was skipped when reconstruction
  succeeded; now reportProgress is called right before early return
This commit is contained in:
Hermes Agent
2026-05-03 13:31:47 +00:00
parent 2017447e81
commit 8d894b08e6
5 changed files with 164 additions and 269 deletions
+52 -196
View File
@@ -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<InputMode>('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<SessionInfo[]>([]);
const [receivedFile, setReceivedFile] = useState<ReceivedFile | null>(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() {
<div style={{ position: 'absolute', bottom: '25%', right: '25%', width: 16, height: 16, borderBottom: '3px solid #58a6ff', borderRight: '3px solid #58a6ff', pointerEvents: 'none' }} />
</div>
<canvas ref={canvasRef} style={{ display: 'none' }} />
{/* Inline stats above controls */}
{scanning && (
<div style={S.statsBar}>
<span>
scanned <span style={S.statValue}>{totalFrames}</span>
</span>
<span>
useful <span style={S.statValue}>{acceptedPackets}</span>
</span>
<span>
need <span style={S.statValue}>{neededPackets}</span>
</span>
<span>·</span>
<span>
gen <span style={S.statValue}>{solvedGens}</span> of <span style={S.statValue}>{totalGens}</span>
</span>
<span>·</span>
<span>{status || 'Working…'}</span>
</div>
)}
<div style={{ ...S.row, marginTop: 10 }}>
{!scanning ? (
<button style={S.btn} onClick={startCameraScanning}>
@@ -626,106 +561,27 @@ export function ReceiverPage() {
Upload a QR-over-GIF file generated by the Sender.
</p>
<input type="file" accept=".gif,image/gif" onChange={handleGifFile} />
{scanning && (
<div style={S.statsBar}>
<span>
scanned <span style={S.statValue}>{totalFrames}</span>
</span>
<span>
useful <span style={S.statValue}>{acceptedPackets}</span>
</span>
<span>
need <span style={S.statValue}>{neededPackets}</span>
</span>
<span>·</span>
<span>
gen <span style={S.statValue}>{solvedGens}</span> of <span style={S.statValue}>{totalGens}</span>
</span>
</div>
)}
{error && <div style={{ ...S.warn, marginTop: 8 }}> {error}</div>}
</div>
)}
{/* ── Status + progress ────────────────────────────────────────────────── */}
<div style={S.section}>
<div style={S.label}>Status</div>
<div style={{ ...S.row, gap: 16 }}>
<span>
<strong>Status:</strong> {status || 'Idle'}
</span>
<span>
<strong>Generations:</strong> {solvedGens}/{totalGens}
</span>
</div>
{totalGens > 0 && (
<div style={S.progressOuter}>
<div style={S.progressInner(progress * 100)} />
</div>
)}
{scanning && (
<div style={{ marginTop: 8, fontSize: 13, color: '#8b949e' }}>
<span style={S.sp} />{' '}
{status || 'Working…'}
</div>
)}
</div>
{/* ── Frame statistics ───────────────────────────────────────────────── */}
{scanning && (
<div style={S.section}>
<div style={S.label}>Frame Statistics</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
<div style={{ textAlign: 'center', padding: '10px 0', background: '#0d1117', borderRadius: 6 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: '#58a6ff' }}>{totalFrames}</div>
<div style={{ fontSize: 11, color: '#8b949e', marginTop: 2 }}>Scanned</div>
</div>
<div style={{ textAlign: 'center', padding: '10px 0', background: '#0d1117', borderRadius: 6 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: '#3fb950' }}>{acceptedPackets}</div>
<div style={{ fontSize: 11, color: '#8b949e', marginTop: 2 }}>Useful (linearly independent)</div>
</div>
<div style={{ textAlign: 'center', padding: '10px 0', background: '#0d1117', borderRadius: 6 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: '#d29922' }}>{neededPackets}</div>
<div style={{ fontSize: 11, color: '#8b949e', marginTop: 2 }}>Minimal needed</div>
</div>
</div>
<p style={{ fontSize: 11, color: '#8b949e', marginTop: 8 }}>
<strong>Scanned</strong> = total frames with valid QR codes seen.&nbsp;
<strong>Useful</strong> = linearly independent symbols accepted by the decoder.&nbsp;
<strong>Minimal needed</strong> = K × totalGenerations ({16} symbols per generation).
</p>
</div>
)}
{/* ── Sessions table ─────────────────────────────────────────────────────── */}
{sessions.length > 0 && (
<div style={S.section}>
<div style={S.label}>Sessions</div>
<table style={S.table}>
<thead>
<tr>
<th style={S.th}>Session ID</th>
<th style={S.th}>Progress</th>
<th style={S.th}>Generations</th>
<th style={S.th}>Status</th>
</tr>
</thead>
<tbody>
{sessions.map((s) => (
<tr key={s.sessionId}>
<td style={S.td}>{s.sessionId}</td>
<td style={S.td}>
<div
style={{
...S.progressOuter,
marginTop: 0,
width: 100,
display: 'inline-block',
verticalAlign: 'middle',
}}
>
<div style={S.progressInner(s.progress * 100)} />
</div>
<span style={{ marginLeft: 8, fontSize: 12 }}>
{Math.round(s.progress * 100)}%
</span>
</td>
<td style={S.td}>
{s.solvedGenerations}/{s.totalGenerations}
</td>
<td style={S.td}>
<span style={S.statusBadge(s.status)}>{s.status}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ── Received text ───────────────────────────────────────────────────── */}
{receivedText && (
<div style={S.section}>
+8 -1
View File
@@ -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],
);
});
+37 -12
View File
@@ -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++) {
+63 -58
View File
@@ -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<string>;
@@ -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<number, SessionState>();
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,
+4 -2
View File
@@ -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<EncodeInput>) => {
const msg = e.data;
@@ -52,7 +54,7 @@ self.onmessage = (e: MessageEvent<EncodeInput>) => {
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 {