update ui

This commit is contained in:
infrost
2026-07-08 17:23:09 +01:00
parent c4d591efc3
commit cb901e20e6
6 changed files with 166 additions and 11 deletions
+26 -2
View File
@@ -10,6 +10,19 @@ Live demo: https://qr.linkto.host/
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Finfrost%2Fraptorqr)
## Table of Contents
* [Performance](#performance)
* [Packages](#packages)
* [Features](#features)
* [FAQ](#faq)
* [Development](#development)
* [Deploy Web App On Vercel](#deploy-web-app-on-vercel)
* [CLI](#cli)
* [WASM Artifacts](#wasm-artifacts)
* [Implementation Notes](#implementation-notes)
## Performance
RaptorQR uses the Rust [`cberner/raptorq`](https://github.com/cberner/raptorq) implementation of RaptorQ (RFC 6330), compiled to WASM, as its primary fountain-code codec. This project also compiles [`erwanvivien/fast_qr`](https://github.com/erwanvivien/fast_qr) to WASM for high-speed QR rendering, with a more feature-complete wrapper than the upstream WASM package, and uses ZXing WASM for scanning.
@@ -23,9 +36,9 @@ Measured examples:
| V20 QR, 4-code parallel playback, 30 FPS | up to 300 decoded QR symbols/s |
| V30 QR, 4-code parallel playback, 30 FPS | 100+ decoded QR symbols/s |
| 95.2 KB file transfer (V30-L x 4QR@30fps) | 375 ms, about 254.0 KB/s |
| 3.0 MB file transfer (V30-L x 4QR@30fps) | about 100 KB/s |
| 6.5 MB file transfer (V30-L x 4QR@30fps) | 36 s, about 183.6 KB/s |
The 95.2 KB and 3.0 MB file tests were measured on **iPhone 16 / Safari as QR scanner**. Actual speed depends on device camera quality, browser performance, lighting, QR size, QR version, playback rate, and scan settings.
The 95.2 KB and 6.5 MB file tests were measured on **iPhone 16 / Safari as QR scanner**. Actual speed depends on device camera quality, browser performance, lighting, QR size, QR version, playback rate, and scan settings.
The current RaptorQ WASM path is intended to be production-ready for local offline transfer workflows.
@@ -50,6 +63,17 @@ apps/web Preact/Vite web app
* Parallel QR playback, live Canvas rendering, and optional GIF export
* Adjustable QR version, ECC level, playback FPS, scan FPS, and repair overhead
## FAQ
### Can I use RaptorQR offline?
Yes. The web app includes `sw.js` and is already PWA-ready. After the first load, you can open the same link again even without an internet connection.
### Does RaptorQR upload my files anywhere?
No. Transfers run locally in the browser or terminal. Files and text are encoded into animated QR codes on the sender side and decoded from the camera feed on the receiver side.
## Development
Install dependencies:
+1 -1
View File
@@ -84,7 +84,7 @@ export function App() {
return (
<div style={styles.container}>
<header style={styles.header}>
<span style={styles.logo}> QR-over-GIF</span>
<span style={styles.logo}> RaptorQR</span>
<nav style={styles.tabBar}>
<button
style={{ ...styles.tab, ...(tab === 'sender' ? styles.tabActive : {}) }}
+102 -3
View File
@@ -194,6 +194,17 @@ const S = {
resize: 'vertical' as const,
minHeight: 120,
} as CSSProps,
hiddenInput: {
display: 'none',
} as CSSProps,
fileName: {
color: '#c9d1d9',
fontSize: 14,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap' as const,
} as CSSProps,
checkboxLabel: {
display: 'inline-flex',
alignItems: 'center',
@@ -211,6 +222,8 @@ export function ReceiverPage() {
const videoContainerRef = useRef<HTMLDivElement>(null);
const textResultRef = useRef<HTMLDivElement>(null);
const fileResultRef = useRef<HTMLDivElement>(null);
const gifFileInputRef = useRef<HTMLInputElement>(null);
const copyResetTimerRef = useRef<number | null>(null);
const workerRef = useRef<Worker | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const animRef = useRef<number>(0);
@@ -227,6 +240,8 @@ export function ReceiverPage() {
const [neededPackets, setNeededPackets] = useState(0);
const [receivedFile, setReceivedFile] = useState<ReceivedFile | null>(null);
const [receivedText, setReceivedText] = useState('');
const [textCopied, setTextCopied] = useState(false);
const [gifFileName, setGifFileName] = useState('');
const [error, setError] = useState('');
const [zoomLevel, setZoomLevel] = useState(1);
const [hasZoomSupport, setHasZoomSupport] = useState(false);
@@ -340,6 +355,7 @@ export function ReceiverPage() {
case 'complete': {
if (msg.isText) {
setReceivedText(msg.text);
setTextCopied(false);
setReceivedFile(null);
} else {
setReceivedFile({
@@ -348,6 +364,7 @@ export function ReceiverPage() {
mime: msg.mime ?? 'application/octet-stream',
});
setReceivedText('');
setTextCopied(false);
}
const elapsed = scanStartRef.current > 0 ? Date.now() - scanStartRef.current : 0;
setElapsedMs(elapsed);
@@ -448,6 +465,7 @@ export function ReceiverPage() {
setError('');
setReceivedFile(null);
setReceivedText('');
setTextCopied(false);
setTotalFrames(0);
setFramesWithQR(0);
setUniquePackets(0);
@@ -530,9 +548,11 @@ export function ReceiverPage() {
const file = input.files?.[0];
if (!file) return;
setGifFileName(`${file.name} · ${formatBytes(file.size)}`);
setError('');
setReceivedFile(null);
setReceivedText('');
setTextCopied(false);
setTotalFrames(0);
setFramesWithQR(0);
setUniquePackets(0);
@@ -666,6 +686,30 @@ export function ReceiverPage() {
URL.revokeObjectURL(url);
}, [receivedFile]);
const handleChooseGifFile = useCallback(() => {
gifFileInputRef.current?.click();
}, []);
const handleCopyRecoveredText = useCallback(async () => {
if (!receivedText) return;
try {
await copyTextToClipboard(receivedText);
setTextCopied(true);
if (copyResetTimerRef.current !== null) {
window.clearTimeout(copyResetTimerRef.current);
}
copyResetTimerRef.current = window.setTimeout(() => {
setTextCopied(false);
copyResetTimerRef.current = null;
}, 1500);
} catch (err: any) {
setError(`Copy failed: ${err.message ?? String(err)}`);
}
}, [receivedText]);
// ── Cleanup on unmount ─────────────────────────────────────────────────
useEffect(() => {
return () => {
@@ -676,6 +720,9 @@ export function ReceiverPage() {
if (workerRef.current) {
workerRef.current.terminate();
}
if (copyResetTimerRef.current !== null) {
window.clearTimeout(copyResetTimerRef.current);
}
};
}, []);
@@ -974,9 +1021,28 @@ export function ReceiverPage() {
<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.
Upload a RaptorQR file generated by the Sender.
</p>
<input type="file" accept=".gif,image/gif" onChange={handleGifFile} />
<div style={S.row}>
<input
ref={gifFileInputRef}
type="file"
accept=".gif,image/gif"
style={S.hiddenInput}
onChange={handleGifFile}
/>
<button
type="button"
style={S.btnSecondary}
disabled={scanning}
onClick={handleChooseGifFile}
>
Choose GIF
</button>
<span style={S.fileName} title={gifFileName}>
{scanning ? 'Processing GIF…' : gifFileName || 'No GIF selected'}
</span>
</div>
{scanning && (
<div style={S.statsBar}>
<span>
@@ -1051,7 +1117,16 @@ export function ReceiverPage() {
{/* ── Received text ────────────────────────────────────────────────────────────────── */}
{receivedText && (
<div style={S.section} ref={textResultRef}>
<div style={S.label}>Recovered Text</div>
<div style={{ ...S.row, justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ ...S.label, marginBottom: 0 }}>Recovered Text</div>
<button
type="button"
style={S.btnSecondary}
onClick={handleCopyRecoveredText}
>
{textCopied ? 'Copied' : 'Copy text'}
</button>
</div>
<textarea
style={S.textarea}
value={receivedText}
@@ -1086,6 +1161,30 @@ function formatBytes(n: number): string {
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
async function copyTextToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.select();
try {
if (!document.execCommand('copy')) {
throw new Error('Clipboard command was rejected.');
}
} finally {
document.body.removeChild(textarea);
}
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const s = Math.floor(ms / 1000);
+34 -2
View File
@@ -205,6 +205,17 @@ const S = {
padding: '9px 10px',
fontSize: 14,
} as CSSProps,
hiddenInput: {
display: 'none',
} as CSSProps,
fileName: {
color: '#c9d1d9',
fontSize: 14,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap' as const,
} as CSSProps,
warn: {
background: '#3d2600',
border: '1px solid #bb8009',
@@ -280,6 +291,7 @@ export function SenderPage() {
const [fullscreenActive, setFullscreenActive] = useState(false);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const qrStageRef = useRef<HTMLDivElement | null>(null);
const liveTransferRef = useRef<LiveTransfer | null>(null);
const encodeWorkerRef = useRef<Worker | null>(null);
@@ -697,6 +709,10 @@ export function SenderPage() {
resetOutput();
}, [resetOutput]);
const handleChooseFile = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleQRVersionChange = useCallback((value: string) => {
setQrVersion(parseQRVersionOption(value));
resetOutput();
@@ -974,8 +990,24 @@ export function SenderPage() {
onInput={(e) => handleTextChange((e.target as HTMLTextAreaElement).value)}
/>
) : (
<div style={{ marginTop: 10 }}>
<input type="file" onChange={handleFile} />
<div style={{ ...S.row, marginTop: 10 }}>
<input
ref={fileInputRef}
type="file"
style={S.hiddenInput}
onChange={handleFile}
/>
<button
type="button"
style={S.btnSecondary}
disabled={encodingLive}
onClick={handleChooseFile}
>
Choose file
</button>
<span style={S.fileName} title={file?.name ?? ''}>
{file ? `${file.name} · ${formatBytes(file.size)}` : 'No file selected'}
</span>
</div>
)}
</div>
+2 -2
View File
@@ -1,5 +1,5 @@
/**
* Minimal GIF frame extractor for QR-over-GIF transfer system.
* Minimal GIF frame extractor for RaptorQR transfer system.
*
* Parses GIF87a/GIF89a format and extracts individual frame pixel data.
* Handles the specific kind of GIFs we generate:
@@ -385,7 +385,7 @@ export function gifFrameToRgba(frame: GifFrame): Uint8ClampedArray {
/**
* Composite all GIF frames into a single RGBA canvas (handles disposal).
*
* For QR-over-GIF, each frame is a full image so compositing is simple:
* For RaptorQR, each frame is a full image so compositing is simple:
* each frame replaces the entire canvas.
*
* @param gif - Parsed GIF data
@@ -30,7 +30,7 @@ function seededShuffle<T>(arr: readonly T[], seed: number): T[] {
}
/**
* Creates the final ordered frame sequence for QR-over-GIF transmission.
* Creates the final ordered frame sequence for RaptorQR transmission.
*
* Interleaves all symbols round-robin across generations. Systematic
* symbols for all generations are sent before coded symbols.