mirror of
https://github.com/infrost/RaptorQR.git
synced 2026-08-30 22:48:37 +08:00
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:
+52
-196
@@ -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.
|
||||
<strong>Useful</strong> = linearly independent symbols accepted by the decoder.
|
||||
<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}>
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user