feat(receiver): informative frame stats, camera zoom, crop overlay, jsQR opts

- Frame count now shows: scanned / useful (linearly independent) / minimal needed
- Camera zoom via getUserMedia track.applyConstraints when supported
- Software crop fallback with visible dashed-square overlay on video
- Corner markers on scan region for visual feedback
- qr_decode accepts inversionAttempts option for faster GIF decoding
- Preserved attemptBoth for camera scanning (handles glare/reflections)
This commit is contained in:
Hermes Agent
2026-05-03 13:10:08 +00:00
parent bbe49ddee8
commit 2017447e81
3 changed files with 194 additions and 39 deletions
+158 -23
View File
@@ -177,6 +177,7 @@ const S = {
export function ReceiverPage() {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const videoContainerRef = useRef<HTMLDivElement>(null);
const workerRef = useRef<Worker | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const animRef = useRef<number>(0);
@@ -186,13 +187,18 @@ export function ReceiverPage() {
const [scanning, setScanning] = useState(false);
const [status, setStatus] = useState('');
const [progress, setProgress] = useState(0);
const [framesDecoded, setFramesDecoded] = 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('');
const [zoomLevel, setZoomLevel] = useState(1);
const [hasZoomSupport, setHasZoomSupport] = useState(false);
// ── Create decode worker ───────────────────────────────────────────────
function createWorker(): Worker {
@@ -205,7 +211,10 @@ export function ReceiverPage() {
const msg = e.data;
switch (msg.type) {
case 'progress': {
setFramesDecoded(msg.framesDecoded);
setTotalFrames(msg.totalFrames ?? 0);
setFramesWithQR(msg.framesWithQR ?? 0);
setAcceptedPackets(msg.acceptedPackets ?? 0);
setNeededPackets(msg.neededPackets ?? 0);
setSolvedGens(msg.solvedGenerations);
setTotalGens(msg.totalGenerations);
setProgress(msg.totalGenerations > 0 ? msg.solvedGenerations / msg.totalGenerations : 0);
@@ -223,7 +232,7 @@ export function ReceiverPage() {
progress: msg.totalGenerations > 0 ? msg.solvedGenerations / msg.totalGenerations : 0,
solvedGenerations: msg.solvedGenerations,
totalGenerations: msg.totalGenerations,
framesDecoded: msg.framesDecoded,
framesDecoded: msg.totalFrames ?? 0,
status: 'receiving' as const,
}
: s,
@@ -284,6 +293,27 @@ export function ReceiverPage() {
return w;
}
// ── Try to set camera zoom via getUserMedia constraints ──────────────
const applyCameraZoom = useCallback(async (level: number) => {
const stream = streamRef.current;
if (!stream) return;
const track = stream.getVideoTracks()[0];
if (!track) return;
const capabilities = track.getCapabilities() as any;
if (capabilities?.zoom) {
try {
await track.applyConstraints({
advanced: [{ zoom: level }],
} as any);
setZoomLevel(level);
setHasZoomSupport(true);
} catch (e: any) {
console.warn('Camera zoom failed:', e.message);
}
}
}, []);
// ── Start camera scanning ────────────────────────────────────────────
const startCameraScanning = useCallback(async () => {
setError('');
@@ -291,7 +321,10 @@ export function ReceiverPage() {
setReceivedText('');
setSessions([]);
setProgress(0);
setFramesDecoded(0);
setTotalFrames(0);
setFramesWithQR(0);
setAcceptedPackets(0);
setNeededPackets(0);
setSolvedGens(0);
setTotalGens(0);
@@ -301,6 +334,27 @@ export function ReceiverPage() {
audio: false,
});
streamRef.current = stream;
// Check zoom capabilities
const track = stream.getVideoTracks()[0];
if (track) {
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);
setZoomLevel(idealZoom);
} catch {
// ignore
}
} else {
setHasZoomSupport(false);
setZoomLevel(1);
}
}
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
@@ -340,7 +394,10 @@ export function ReceiverPage() {
setReceivedText('');
setSessions([]);
setProgress(0);
setFramesDecoded(0);
setTotalFrames(0);
setFramesWithQR(0);
setAcceptedPackets(0);
setNeededPackets(0);
setSolvedGens(0);
setTotalGens(0);
@@ -401,9 +458,11 @@ export function ReceiverPage() {
workerRef.current = null;
}
setStatus('Stopped');
setHasZoomSupport(false);
setZoomLevel(1);
}, []);
// ── Capture frame from camera (with 3× digital zoom crop) ────────────────
// ── Capture frame from camera (software crop + optional camera zoom) ───
const captureFrame = useCallback(() => {
const video = videoRef.current;
const canvas = canvasRef.current;
@@ -413,25 +472,37 @@ export function ReceiverPage() {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const cw = Math.min(video.videoWidth || 640, 640);
const ch = Math.min(video.videoHeight || 640, 640);
const vw = video.videoWidth || 640;
const vh = video.videoHeight || 640;
// Output canvas size: cap at 640 for performance, but preserve aspect ratio
const maxCanvas = 640;
const aspect = vw / vh;
let cw: number, ch: number;
if (aspect >= 1) {
cw = Math.min(vw, maxCanvas);
ch = Math.round(cw / aspect);
} else {
ch = Math.min(vh, maxCanvas);
cw = Math.round(ch * aspect);
}
canvas.width = cw;
canvas.height = ch;
const vw = video.videoWidth || 640;
const vh = video.videoHeight || 640;
const minDim = Math.min(vw, vh);
// Crop region: center 50% of the frame (2× software zoom)
// If camera zoom is active, the video already shows a zoomed view,
// so we crop less aggressively.
const cropRatio = zoomLevel > 1 ? 0.6 : 0.5;
const cropW = vw * cropRatio;
const cropH = vh * cropRatio;
const sx = (vw - cropW) / 2;
const sy = (vh - cropH) / 2;
// 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);
ctx.drawImage(video, sx, sy, cropW, cropH, 0, 0, cw, ch);
const imageData = ctx.getImageData(0, 0, cw, ch);
worker.postMessage({ type: 'frame', imageData });
}, []);
}, [zoomLevel]);
// ── Download recovered file ─────────────────────────────────────────────
const handleDownload = useCallback(() => {
@@ -486,7 +557,31 @@ export function ReceiverPage() {
{inputMode === 'camera' && (
<div style={S.section}>
<div style={S.label}>Camera</div>
<video ref={videoRef} style={S.video} playsInline muted />
<div
ref={videoContainerRef}
style={{ position: 'relative', display: 'inline-block', maxWidth: 480, width: '100%' }}
>
<video ref={videoRef} style={{ width: '100%', borderRadius: 6, background: '#000', display: 'block' }} playsInline muted />
{/* Scan-region overlay */}
<div
style={{
position: 'absolute',
top: '25%',
left: '25%',
width: '50%',
height: '50%',
border: '2px dashed rgba(88, 166, 255, 0.7)',
borderRadius: 8,
pointerEvents: 'none',
boxShadow: '0 0 0 9999px rgba(0,0,0,0.35)',
}}
/>
{/* Corner markers */}
<div style={{ position: 'absolute', top: '25%', left: '25%', width: 16, height: 16, borderTop: '3px solid #58a6ff', borderLeft: '3px solid #58a6ff', pointerEvents: 'none' }} />
<div style={{ position: 'absolute', top: '25%', right: '25%', width: 16, height: 16, borderTop: '3px solid #58a6ff', borderRight: '3px solid #58a6ff', pointerEvents: 'none' }} />
<div style={{ position: 'absolute', bottom: '25%', left: '25%', width: 16, height: 16, borderBottom: '3px solid #58a6ff', borderLeft: '3px solid #58a6ff', pointerEvents: 'none' }} />
<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' }} />
<div style={{ ...S.row, marginTop: 10 }}>
{!scanning ? (
@@ -498,9 +593,26 @@ export function ReceiverPage() {
Stop Scan
</button>
)}
{hasZoomSupport && scanning && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 12, color: '#8b949e' }}>Zoom:</span>
<input
type="range"
min={1}
max={3}
step={0.5}
value={zoomLevel}
onChange={(e) => applyCameraZoom(Number((e.target as HTMLInputElement).value))}
style={{ width: 120 }}
/>
<span style={{ fontSize: 12, color: '#c9d1d9', minWidth: 30 }}>{zoomLevel}×</span>
</div>
)}
</div>
<p style={{ fontSize: 12, color: '#8b949e', marginTop: 6 }}>
3× digital zoom is applied automatically to the center of the frame.
{hasZoomSupport
? 'Camera zoom is active. The dashed square shows the scan region.'
: 'Software crop is applied to the center 50% of the frame (dashed square).'}
</p>
{error && <div style={S.warn}> {error}</div>}
</div>
@@ -525,9 +637,6 @@ export function ReceiverPage() {
<span>
<strong>Status:</strong> {status || 'Idle'}
</span>
<span>
<strong>Frames:</strong> {framesDecoded}
</span>
<span>
<strong>Generations:</strong> {solvedGens}/{totalGens}
</span>
@@ -545,6 +654,32 @@ export function ReceiverPage() {
)}
</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}>
+6 -2
View File
@@ -51,14 +51,18 @@ function extractBytes(result: { chunks?: Array<{ bytes?: number[]; text?: string
* the QR decode process. Returns null if no QR code is found.
*
* @param imageData RGBA pixel data from a canvas (width × height × 4 bytes)
* @param opts Optional jsQR options (e.g. inversionAttempts)
* @returns The decoded raw bytes, or `null` if no QR code could be found/decoded.
*/
export function decodeQRFromCanvas(imageData: ImageData): Uint8Array | null {
export function decodeQRFromCanvas(
imageData: ImageData,
opts?: { inversionAttempts?: 'attemptBoth' | 'dontInvert' | 'onlyInvert' | 'invertFirst' },
): Uint8Array | null {
const result = jsQR(
imageData.data,
imageData.width,
imageData.height,
{ inversionAttempts: 'attemptBoth' },
{ inversionAttempts: opts?.inversionAttempts ?? 'attemptBoth' },
);
if (!result) return null;
return extractBytes(result);
+30 -14
View File
@@ -26,8 +26,9 @@ interface SessionState {
isCompressed: boolean;
completed: boolean;
stats: {
framesDecoded: number;
framesWithQR: number;
totalFrames: number; // every frame received by worker
framesWithQR: number; // frames where QR was found
acceptedPackets: number; // linearly independent symbols accepted
};
}
@@ -71,8 +72,19 @@ self.onmessage = (e: MessageEvent) => {
// ─── Frame handling ──────────────────────────────────────────────────────────
function handleFrame(imageData: ImageData): void {
const decoded = decodeQRFromCanvas(imageData);
if (!decoded) return;
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;
@@ -84,14 +96,14 @@ function handleFrame(imageData: ImageData): void {
}
const h = packet.header;
const sid = h.sessionId;
const sessionId = h.sessionId;
// Get or create session state
let state = sessions.get(sid);
state = sessions.get(sessionId);
if (!state) {
state = {
sessionId: sid,
decoder: new GenerationDecoder(K, MAX_PAYLOAD_SIZE, sid, 0),
sessionId,
decoder: new GenerationDecoder(K, MAX_PAYLOAD_SIZE, sessionId, 0),
dedup: new Set(),
receivedPackets: 0,
solvedGenerations: new Set(),
@@ -100,23 +112,23 @@ function handleFrame(imageData: ImageData): void {
isText: (h.flags & 1) !== 0,
isCompressed: (h.flags & 2) !== 0,
completed: false,
stats: { framesDecoded: 0, framesWithQR: 0 },
stats: { totalFrames: 0, framesWithQR: 0, acceptedPackets: 0 },
};
sessions.set(sid, state);
sessions.set(sessionId, state);
}
if (state.completed) return;
// Update metadata from header (in case first packet was incomplete)
// Update metadata from header
state.totalGenerations = h.totalGenerations;
state.dataLength = h.dataLength;
state.isText = (h.flags & 1) !== 0;
state.isCompressed = (h.flags & 2) !== 0;
state.stats.framesDecoded++;
state.stats.totalFrames++;
// Dedup: sessionId:generationIndex:packetType:symbolIndex
const dedupKey = `${sid}:${h.generationIndex}:${h.packetType}:${h.symbolIndex}`;
const dedupKey = `${sessionId}:${h.generationIndex}:${h.packetType}:${h.symbolIndex}`;
if (state.dedup.has(dedupKey)) return;
state.dedup.add(dedupKey);
state.stats.framesWithQR++;
@@ -132,6 +144,7 @@ function handleFrame(imageData: ImageData): void {
}
if (accepted) {
state.stats.acceptedPackets++;
state.receivedPackets++;
if (state.decoder.isSolved(gen)) {
@@ -227,12 +240,15 @@ function reconstructData(state: SessionState): void {
function reportProgress(state: SessionState): void {
const totalGens = state.totalGenerations;
const solvedGens = state.solvedGenerations.size;
const needed = totalGens > 0 ? K * totalGens : 0;
self.postMessage({
type: 'progress',
sessionId: state.sessionId,
framesDecoded: state.stats.framesDecoded,
totalFrames: state.stats.totalFrames,
framesWithQR: state.stats.framesWithQR,
acceptedPackets: state.stats.acceptedPackets,
neededPackets: needed,
receivedPackets: state.receivedPackets,
solvedGenerations: solvedGens,
totalGenerations: totalGens,