mirror of
https://github.com/infrost/RaptorQR.git
synced 2026-09-03 00:17:49 +08:00
enable Parallel QR
This commit is contained in:
+11
-30
@@ -384,13 +384,7 @@ export function ReceiverPage() {
|
||||
const capabilities = track.getCapabilities() as any;
|
||||
if (capabilities?.zoom) {
|
||||
setHasZoomSupport(true);
|
||||
const idealZoom = Math.min(2, capabilities.zoom.max ?? 2);
|
||||
try {
|
||||
await track.applyConstraints({ advanced: [{ zoom: idealZoom }] } as any);
|
||||
setZoomLevel(idealZoom);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setZoomLevel(1);
|
||||
} else {
|
||||
setHasZoomSupport(false);
|
||||
setZoomLevel(1);
|
||||
@@ -511,7 +505,7 @@ export function ReceiverPage() {
|
||||
}, []);
|
||||
|
||||
|
||||
// ── Capture frame from camera (software crop + optional camera zoom) ───────
|
||||
// ── Capture the full camera frame ─────────────────────────────────────────
|
||||
const captureFrame = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
@@ -538,20 +532,11 @@ export function ReceiverPage() {
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
|
||||
// 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;
|
||||
|
||||
ctx.drawImage(video, sx, sy, cropW, cropH, 0, 0, cw, ch);
|
||||
ctx.drawImage(video, 0, 0, vw, vh, 0, 0, cw, ch);
|
||||
const imageData = ctx.getImageData(0, 0, cw, ch);
|
||||
|
||||
worker.postMessage({ type: 'frame', imageData, realtime: true });
|
||||
}, [zoomLevel]);
|
||||
}, []);
|
||||
|
||||
// ── Download recovered file ──────────────────────────────────────────────
|
||||
const handleDownload = useCallback(() => {
|
||||
@@ -634,21 +619,17 @@ export function ReceiverPage() {
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '25%',
|
||||
left: '25%',
|
||||
width: '50%',
|
||||
height: '50%',
|
||||
inset: 0,
|
||||
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 style={{ position: 'absolute', top: 0, left: 0, width: 16, height: 16, borderTop: '3px solid #58a6ff', borderLeft: '3px solid #58a6ff', pointerEvents: 'none' }} />
|
||||
<div style={{ position: 'absolute', top: 0, right: 0, width: 16, height: 16, borderTop: '3px solid #58a6ff', borderRight: '3px solid #58a6ff', pointerEvents: 'none' }} />
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, width: 16, height: 16, borderBottom: '3px solid #58a6ff', borderLeft: '3px solid #58a6ff', pointerEvents: 'none' }} />
|
||||
<div style={{ position: 'absolute', bottom: 0, right: 0, width: 16, height: 16, borderBottom: '3px solid #58a6ff', borderRight: '3px solid #58a6ff', pointerEvents: 'none' }} />
|
||||
</div>
|
||||
<canvas ref={canvasRef} style={{ display: 'none' }} />
|
||||
|
||||
@@ -711,8 +692,8 @@ export function ReceiverPage() {
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: '#8b949e', marginTop: 6 }}>
|
||||
{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).'}
|
||||
? 'Full-frame scan is active. Use Zoom only if the QR codes are too small.'
|
||||
: 'Full-frame scan is active.'}
|
||||
</p>
|
||||
{error && <div style={S.warn}>⚠ {error}</div>}
|
||||
</div>
|
||||
|
||||
+106
-20
@@ -14,6 +14,7 @@ import {
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type InputMode = 'text' | 'file';
|
||||
type ParallelQRCount = 1 | 2 | 4;
|
||||
|
||||
interface GifResult {
|
||||
gifData: ArrayBuffer;
|
||||
@@ -22,17 +23,23 @@ interface GifResult {
|
||||
frameCount: number;
|
||||
frameRateFps: number;
|
||||
frameDelayMs: number;
|
||||
parallelCount: ParallelQRCount;
|
||||
}
|
||||
|
||||
interface LiveTransfer {
|
||||
packets: Uint8Array[];
|
||||
width: number;
|
||||
height: number;
|
||||
tileWidth: number;
|
||||
tileHeight: number;
|
||||
columns: number;
|
||||
rows: number;
|
||||
version: number;
|
||||
eccLevel: QRTransferProfile['eccLevel'];
|
||||
symbolSize: number;
|
||||
scale: number;
|
||||
frameCount: number;
|
||||
displayFrameCount: number;
|
||||
parallelCount: ParallelQRCount;
|
||||
}
|
||||
|
||||
interface FrameCache {
|
||||
@@ -47,6 +54,8 @@ type CSSProps = Record<string, string | number>;
|
||||
const MIN_FRAME_RATE_FPS = 2;
|
||||
const MAX_FRAME_RATE_FPS = 60;
|
||||
const DEFAULT_FRAME_RATE_FPS = 30;
|
||||
const DEFAULT_PARALLEL_QR_COUNT: ParallelQRCount = 1;
|
||||
const PARALLEL_QR_COUNTS: ParallelQRCount[] = [1, 2, 4];
|
||||
const LIVE_TARGET_PX = 360;
|
||||
const QR_QUIET_ZONE_MODULES = 4;
|
||||
const FRAME_CACHE_LIMIT = 120;
|
||||
@@ -125,9 +134,10 @@ const S = {
|
||||
maxWidth: '100%',
|
||||
}),
|
||||
fullscreenPreview: {
|
||||
width: 'min(100vw, 100vh)',
|
||||
height: 'min(100vw, 100vh)',
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
borderRadius: 0,
|
||||
objectFit: 'contain',
|
||||
} as CSSProps,
|
||||
infoGrid: {
|
||||
display: 'grid',
|
||||
@@ -207,6 +217,7 @@ export function SenderPage() {
|
||||
const [status, setStatus] = useState('');
|
||||
const [qrProfileId, setQrProfileId] = useState(DEFAULT_QR_PROFILE_ID);
|
||||
const [frameRateFps, setFrameRateFps] = useState(DEFAULT_FRAME_RATE_FPS);
|
||||
const [parallelQRCount, setParallelQRCount] = useState<ParallelQRCount>(DEFAULT_PARALLEL_QR_COUNT);
|
||||
const [liveTransfer, setLiveTransfer] = useState<LiveTransfer | null>(null);
|
||||
const [gifResult, setGifResult] = useState<GifResult | null>(null);
|
||||
const [stats, setStats] = useState<{ originalSize: number; preprocessedSize: number; frameCount: number; totalGenerations: number } | null>(null);
|
||||
@@ -240,9 +251,9 @@ export function SenderPage() {
|
||||
if (!transfer || !canvas) return;
|
||||
|
||||
try {
|
||||
const frameIndex = liveFrameIndexRef.current % transfer.frameCount;
|
||||
const frameIndex = liveFrameIndexRef.current % transfer.displayFrameCount;
|
||||
drawLiveFrame(canvas, transfer, frameIndex, frameCacheRef.current);
|
||||
liveFrameIndexRef.current = (frameIndex + 1) % transfer.frameCount;
|
||||
liveFrameIndexRef.current = (frameIndex + 1) % transfer.displayFrameCount;
|
||||
scheduleNextLiveFrame();
|
||||
} catch (err: any) {
|
||||
clearPlaybackTimer();
|
||||
@@ -262,9 +273,9 @@ export function SenderPage() {
|
||||
liveFrameIndexRef.current = 0;
|
||||
}
|
||||
|
||||
const frameIndex = liveFrameIndexRef.current % transfer.frameCount;
|
||||
const frameIndex = liveFrameIndexRef.current % transfer.displayFrameCount;
|
||||
drawLiveFrame(canvas, transfer, frameIndex, frameCacheRef.current);
|
||||
liveFrameIndexRef.current = (frameIndex + 1) % transfer.frameCount;
|
||||
liveFrameIndexRef.current = (frameIndex + 1) % transfer.displayFrameCount;
|
||||
scheduleNextLiveFrame();
|
||||
}, [clearPlaybackTimer, scheduleNextLiveFrame]);
|
||||
|
||||
@@ -339,6 +350,11 @@ export function SenderPage() {
|
||||
}
|
||||
}, [startLivePlayback]);
|
||||
|
||||
const handleParallelQRCountChange = useCallback((value: string) => {
|
||||
setParallelQRCount(normalizeParallelQRCount(Number(value)));
|
||||
resetOutput();
|
||||
}, [resetOutput]);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
resetOutput();
|
||||
|
||||
@@ -406,8 +422,16 @@ export function SenderPage() {
|
||||
frameCount: encoded.stats.frameCount,
|
||||
totalGenerations: encoded.totalGenerations,
|
||||
});
|
||||
setLiveTransfer(createLiveTransfer(encoded.packets, selectedQRProfile));
|
||||
setStatus(`Live QR running (${encoded.stats.frameCount} frames). Preparing GIF download…`);
|
||||
const nextLiveTransfer = createLiveTransfer(
|
||||
encoded.packets,
|
||||
selectedQRProfile,
|
||||
parallelQRCount,
|
||||
);
|
||||
setLiveTransfer(nextLiveTransfer);
|
||||
setStatus(
|
||||
`Live QR running (${encoded.stats.frameCount} packets, ` +
|
||||
`${nextLiveTransfer.parallelCount} per tick). Preparing GIF download…`,
|
||||
);
|
||||
|
||||
// ── Step 2: GIF worker ─────────────────────────────────────────
|
||||
const outputFrameRateFps = frameRateFpsRef.current;
|
||||
@@ -429,6 +453,7 @@ export function SenderPage() {
|
||||
frameCount: e.data.frameCount,
|
||||
frameRateFps: outputFrameRateFps,
|
||||
frameDelayMs: outputFrameDelayMs,
|
||||
parallelCount: parallelQRCount,
|
||||
});
|
||||
} else if (e.data.type === 'error') {
|
||||
reject(new Error(e.data.message));
|
||||
@@ -442,6 +467,7 @@ export function SenderPage() {
|
||||
frameDelayMs: outputFrameDelayMs,
|
||||
qrVersion: selectedQRProfile.version,
|
||||
eccLevel: selectedQRProfile.eccLevel,
|
||||
parallelCount: parallelQRCount,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -455,7 +481,7 @@ export function SenderPage() {
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [mode, text, file, resetOutput, qrProfileId]);
|
||||
}, [mode, text, file, resetOutput, qrProfileId, parallelQRCount]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!gifResult) return;
|
||||
@@ -554,6 +580,24 @@ export function SenderPage() {
|
||||
<span>Fast</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ ...S.row, justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<span style={S.label}>Parallel QR</span>
|
||||
<span style={S.infoValue}>{parallelQRCount} per tick</span>
|
||||
</div>
|
||||
<select
|
||||
value={parallelQRCount}
|
||||
style={S.select}
|
||||
disabled={busy}
|
||||
onChange={(e) => handleParallelQRCountChange((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
{PARALLEL_QR_COUNTS.map((count) => (
|
||||
<option key={count} value={count}>
|
||||
{count} QR{count === 1 ? '' : 's'} per tick
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
style={busy ? { ...S.btn, opacity: 0.6, cursor: 'not-allowed' } : S.btn}
|
||||
disabled={busy}
|
||||
@@ -614,8 +658,10 @@ export function SenderPage() {
|
||||
<span style={S.infoValue}>{liveTransfer ? `V${liveTransfer.version}-${liveTransfer.eccLevel}` : qrProfile.label}</span>
|
||||
<span style={S.infoLabel}>Symbol payload</span>
|
||||
<span style={S.infoValue}>{liveTransfer ? `${liveTransfer.symbolSize} B/frame` : `${qrProfile.maxPayloadSize} B/frame`}</span>
|
||||
<span style={S.infoLabel}>Frame count</span>
|
||||
<span style={S.infoLabel}>QR packets</span>
|
||||
<span style={S.infoValue}>{stats.frameCount}</span>
|
||||
<span style={S.infoLabel}>Parallel QR</span>
|
||||
<span style={S.infoValue}>{liveTransfer ? `${liveTransfer.parallelCount} per tick` : `${parallelQRCount} per tick`}</span>
|
||||
<span style={S.infoLabel}>Live speed</span>
|
||||
<span style={S.infoValue}>{frameRateFps} fps ({formatDelayMs(frameDelayMs)} ms)</span>
|
||||
<span style={S.infoLabel}>GIF export speed</span>
|
||||
@@ -644,6 +690,12 @@ function clampFrameRate(value: number): number {
|
||||
return Math.min(MAX_FRAME_RATE_FPS, Math.max(MIN_FRAME_RATE_FPS, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizeParallelQRCount(value: number): ParallelQRCount {
|
||||
return PARALLEL_QR_COUNTS.includes(value as ParallelQRCount)
|
||||
? value as ParallelQRCount
|
||||
: DEFAULT_PARALLEL_QR_COUNT;
|
||||
}
|
||||
|
||||
function frameRateToDelayMs(fps: number): number {
|
||||
return 1000 / clampFrameRate(fps);
|
||||
}
|
||||
@@ -656,7 +708,11 @@ function createFrameCache(): FrameCache {
|
||||
return { frames: new Map(), maxEntries: FRAME_CACHE_LIMIT };
|
||||
}
|
||||
|
||||
function createLiveTransfer(packets: Uint8Array[], profile: QRTransferProfile): LiveTransfer {
|
||||
function createLiveTransfer(
|
||||
packets: Uint8Array[],
|
||||
profile: QRTransferProfile,
|
||||
parallelCount: ParallelQRCount,
|
||||
): LiveTransfer {
|
||||
if (packets.length === 0) {
|
||||
throw new Error('No QR packets were generated.');
|
||||
}
|
||||
@@ -664,17 +720,23 @@ function createLiveTransfer(packets: Uint8Array[], profile: QRTransferProfile):
|
||||
const moduleCount = profile.version * 4 + 17;
|
||||
const totalModules = moduleCount + QR_QUIET_ZONE_MODULES * 2;
|
||||
const scale = Math.max(2, Math.round(LIVE_TARGET_PX / totalModules));
|
||||
const size = totalModules * scale;
|
||||
const tileSize = totalModules * scale;
|
||||
const layout = getParallelLayout(parallelCount);
|
||||
|
||||
return {
|
||||
packets,
|
||||
width: size,
|
||||
height: size,
|
||||
width: tileSize * layout.columns,
|
||||
height: tileSize * layout.rows,
|
||||
tileWidth: tileSize,
|
||||
tileHeight: tileSize,
|
||||
columns: layout.columns,
|
||||
rows: layout.rows,
|
||||
version: profile.version,
|
||||
eccLevel: profile.eccLevel,
|
||||
symbolSize: profile.maxPayloadSize,
|
||||
scale,
|
||||
frameCount: packets.length,
|
||||
displayFrameCount: packets.length,
|
||||
parallelCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -691,15 +753,24 @@ function drawLiveFrame(
|
||||
if (!ctx) throw new Error('Canvas 2D context is unavailable.');
|
||||
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.putImageData(getLiveFrameImage(transfer, frameIndex, cache), 0, 0);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, transfer.width, transfer.height);
|
||||
|
||||
for (let tileIndex = 0; tileIndex < transfer.parallelCount; tileIndex++) {
|
||||
const packetIndex = getPacketIndexForDisplayFrame(transfer, frameIndex, tileIndex);
|
||||
const image = getLivePacketImage(transfer, packetIndex, cache);
|
||||
const x = (tileIndex % transfer.columns) * transfer.tileWidth;
|
||||
const y = Math.floor(tileIndex / transfer.columns) * transfer.tileHeight;
|
||||
ctx.putImageData(image, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
function getLiveFrameImage(
|
||||
function getLivePacketImage(
|
||||
transfer: LiveTransfer,
|
||||
frameIndex: number,
|
||||
packetIndex: number,
|
||||
cache: FrameCache,
|
||||
): ImageData {
|
||||
const cacheKey = frameIndex % transfer.frameCount;
|
||||
const cacheKey = packetIndex % transfer.packets.length;
|
||||
const cached = cache.frames.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
@@ -721,3 +792,18 @@ function getLiveFrameImage(
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
function getParallelLayout(parallelCount: ParallelQRCount): { columns: number; rows: number } {
|
||||
if (parallelCount === 1) return { columns: 1, rows: 1 };
|
||||
if (parallelCount === 2) return { columns: 2, rows: 1 };
|
||||
return { columns: 2, rows: 2 };
|
||||
}
|
||||
|
||||
function getPacketIndexForDisplayFrame(
|
||||
transfer: LiveTransfer,
|
||||
frameIndex: number,
|
||||
tileIndex: number,
|
||||
): number {
|
||||
const laneOffset = Math.floor(tileIndex * transfer.packets.length / transfer.parallelCount);
|
||||
return (frameIndex + laneOffset) % transfer.packets.length;
|
||||
}
|
||||
|
||||
+34
-11
@@ -19,7 +19,6 @@ export interface QrDecodeResult {
|
||||
|
||||
const READER_OPTIONS: ReaderOptions = {
|
||||
formats: ['QRCode'],
|
||||
maxNumberOfSymbols: 1,
|
||||
tryHarder: true,
|
||||
tryRotate: false,
|
||||
tryInvert: true,
|
||||
@@ -27,6 +26,8 @@ const READER_OPTIONS: ReaderOptions = {
|
||||
textMode: 'Plain',
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_QR_SYMBOLS = 4;
|
||||
|
||||
let preparePromise: Promise<unknown> | null = null;
|
||||
|
||||
/**
|
||||
@@ -40,7 +41,17 @@ let preparePromise: Promise<unknown> | null = null;
|
||||
export function decodeQRFromCanvas(
|
||||
imageData: ImageData,
|
||||
): Promise<QrDecodeResult | null> {
|
||||
return decodeImageData(imageData);
|
||||
return decodeImageData(imageData, 1).then((results) => results[0] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode up to `maxSymbols` QR codes from an `ImageData` object.
|
||||
*/
|
||||
export function decodeQRCodesFromCanvas(
|
||||
imageData: ImageData,
|
||||
maxSymbols: number = DEFAULT_MAX_QR_SYMBOLS,
|
||||
): Promise<QrDecodeResult[]> {
|
||||
return decodeImageData(imageData, maxSymbols);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,19 +88,26 @@ export function decodeQRFromBuffer(
|
||||
rgba[off + 3] = 255; // A
|
||||
}
|
||||
|
||||
return decodeImageData(new ImageData(rgba, width, height));
|
||||
return decodeImageData(new ImageData(rgba, width, height), 1)
|
||||
.then((results) => results[0] ?? null);
|
||||
}
|
||||
|
||||
async function decodeImageData(imageData: ImageData): Promise<QrDecodeResult | null> {
|
||||
async function decodeImageData(
|
||||
imageData: ImageData,
|
||||
maxSymbols: number,
|
||||
): Promise<QrDecodeResult[]> {
|
||||
await prepareReader();
|
||||
const results = await readBarcodes(imageData, READER_OPTIONS);
|
||||
const result = results.find((item) => item.isValid && item.symbology === 'QRCode');
|
||||
if (!result || result.bytes.length === 0) return null;
|
||||
const results = await readBarcodes(imageData, {
|
||||
...READER_OPTIONS,
|
||||
maxNumberOfSymbols: clampMaxSymbols(maxSymbols),
|
||||
});
|
||||
|
||||
return {
|
||||
bytes: new Uint8Array(result.bytes),
|
||||
version: parseQRVersion(result.version, result.extra),
|
||||
};
|
||||
return results
|
||||
.filter((item) => item.isValid && item.symbology === 'QRCode' && item.bytes.length > 0)
|
||||
.map((result) => ({
|
||||
bytes: new Uint8Array(result.bytes),
|
||||
version: parseQRVersion(result.version, result.extra),
|
||||
}));
|
||||
}
|
||||
|
||||
function prepareReader(): Promise<unknown> {
|
||||
@@ -125,3 +143,8 @@ function parseQRVersion(version: string, extra: string): number {
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function clampMaxSymbols(value: number): number {
|
||||
if (!Number.isFinite(value)) return DEFAULT_MAX_QR_SYMBOLS;
|
||||
return Math.min(DEFAULT_MAX_QR_SYMBOLS, Math.max(1, Math.round(value)));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { generateQRMatrix, getMaxByteCapacity, getMinVersion } from '../core/qr/qr_encode.ts';
|
||||
import { rasterizeQR, rasterizeToGrayscale, getRasterDimensions } from '../core/qr/frame_raster.ts';
|
||||
import { decodeQRFromBuffer } from '../core/qr/qr_decode.ts';
|
||||
import { decodeQRFromBuffer, decodeQRCodesFromCanvas } from '../core/qr/qr_decode.ts';
|
||||
import { createQRGif, estimateGifSize } from '../core/gif/gif_render.ts';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
@@ -49,6 +49,36 @@ describe('Frame raster', () => {
|
||||
expect(decoded!.version).toBe(1);
|
||||
expect(new TextDecoder().decode(decoded!.bytes)).toBe(original);
|
||||
});
|
||||
|
||||
it('should decode multiple QR codes from one image', async () => {
|
||||
const payloads = ['left QR', 'right QR'];
|
||||
const images = payloads.map((text) => {
|
||||
const matrix = generateQRMatrix(new TextEncoder().encode(text), 1, 'L');
|
||||
return rasterizeQR(matrix, 4);
|
||||
});
|
||||
const tileWidth = images[0]!.width;
|
||||
const tileHeight = images[0]!.height;
|
||||
const width = tileWidth * images.length;
|
||||
const height = tileHeight;
|
||||
const composite = new Uint8ClampedArray(width * height * 4);
|
||||
composite.fill(255);
|
||||
|
||||
images.forEach((image, tileIndex) => {
|
||||
for (let row = 0; row < tileHeight; row++) {
|
||||
const sourceStart = row * tileWidth * 4;
|
||||
const sourceEnd = sourceStart + tileWidth * 4;
|
||||
const targetStart = (row * width + tileIndex * tileWidth) * 4;
|
||||
composite.set(image.data.subarray(sourceStart, sourceEnd), targetStart);
|
||||
}
|
||||
});
|
||||
|
||||
const decoded = await decodeQRCodesFromCanvas(new ImageData(composite, width, height), 2);
|
||||
const texts = decoded
|
||||
.map((result) => new TextDecoder().decode(result.bytes))
|
||||
.sort();
|
||||
|
||||
expect(texts).toEqual([...payloads].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('GIF render', () => {
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
*/
|
||||
|
||||
import { inflateSync } from 'fflate';
|
||||
import { decodeQRFromCanvas } from '@/core/qr/qr_decode';
|
||||
import {
|
||||
decodeQRCodesFromCanvas,
|
||||
type QrDecodeResult,
|
||||
} from '@/core/qr/qr_decode';
|
||||
import { parsePacket } from '@/core/protocol/packet';
|
||||
import type { Packet } from '@/core/protocol/packet';
|
||||
import { K, sourceGenerationsFromTotal } from '@/core/protocol/constants';
|
||||
@@ -41,6 +44,7 @@ interface QueuedFrame {
|
||||
}
|
||||
|
||||
const MAX_REALTIME_FRAME_QUEUE = 60;
|
||||
const MAX_QR_SYMBOLS_PER_FRAME = 4;
|
||||
|
||||
let current: DecodeState | null = null;
|
||||
let frameQueue: QueuedFrame[] = [];
|
||||
@@ -118,16 +122,37 @@ async function processFrameQueue(): Promise<void> {
|
||||
}
|
||||
|
||||
async function handleFrame(imageData: ImageData): Promise<void> {
|
||||
const decoded = await decodeQRFromCanvas(imageData);
|
||||
if (!decoded) return;
|
||||
const decodedSymbols = await decodeQRCodesFromCanvas(imageData, MAX_QR_SYMBOLS_PER_FRAME);
|
||||
if (decodedSymbols.length === 0) return;
|
||||
|
||||
let packet: Packet;
|
||||
try {
|
||||
packet = parsePacket(decoded.bytes);
|
||||
} catch {
|
||||
return;
|
||||
let processedPackets = 0;
|
||||
for (const decoded of decodedSymbols) {
|
||||
let packet: Packet;
|
||||
try {
|
||||
packet = parsePacket(decoded.bytes);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
processDecodedPacket(decoded, packet, processedPackets === 0);
|
||||
processedPackets++;
|
||||
|
||||
if (current?.completed) {
|
||||
reportProgress(current);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (current && processedPackets > 0) {
|
||||
reportProgress(current);
|
||||
}
|
||||
}
|
||||
|
||||
function processDecodedPacket(
|
||||
decoded: QrDecodeResult,
|
||||
packet: Packet,
|
||||
countFrame: boolean,
|
||||
): void {
|
||||
const h = packet.header;
|
||||
|
||||
// Start fresh on first valid packet
|
||||
@@ -153,6 +178,10 @@ async function handleFrame(imageData: ImageData): Promise<void> {
|
||||
|
||||
if (current.completed) return;
|
||||
|
||||
if (countFrame) {
|
||||
current.stats.totalFrames++;
|
||||
}
|
||||
|
||||
if (packet.payload.length !== current.symbolSize) {
|
||||
throw new Error(
|
||||
`QR payload size changed from ${current.symbolSize} to ${packet.payload.length} bytes. ` +
|
||||
@@ -168,7 +197,6 @@ async function handleFrame(imageData: ImageData): Promise<void> {
|
||||
current.isText = h.isText;
|
||||
current.isCompressed = h.compressed;
|
||||
|
||||
current.stats.totalFrames++;
|
||||
current.stats.framesWithQR++;
|
||||
|
||||
// Dedup: generationIndex:symbolIndex
|
||||
@@ -197,15 +225,10 @@ async function handleFrame(imageData: ImageData): Promise<void> {
|
||||
// We only need sourceGenerations generations solved (any mix of source + parity)
|
||||
if (current.solvedGenerations.size >= current.sourceGenerations) {
|
||||
reconstructData(current);
|
||||
if (current.completed) {
|
||||
reportProgress(current);
|
||||
return;
|
||||
}
|
||||
if (current.completed) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reportProgress(current);
|
||||
}
|
||||
|
||||
// ─── Reconstruct original data from all source symbols ────────────────────────
|
||||
|
||||
+50
-10
@@ -11,6 +11,8 @@ import { rasterizeQR } from '@/core/qr/frame_raster';
|
||||
import { createQRGif } from '@/core/gif/gif_render';
|
||||
import { QR_VERSION, ECC_LEVEL, FRAME_DELAY_MS } from '@/core/protocol/constants';
|
||||
|
||||
type ParallelQRCount = 1 | 2 | 4;
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface GenerateInput {
|
||||
@@ -19,6 +21,7 @@ interface GenerateInput {
|
||||
frameDelayMs?: number;
|
||||
qrVersion?: number;
|
||||
eccLevel?: EccLevel;
|
||||
parallelCount?: number;
|
||||
}
|
||||
|
||||
interface GifOutput {
|
||||
@@ -53,6 +56,7 @@ function handleGenerate(input: GenerateInput): GifOutput {
|
||||
const frameDelayMs = normalizeFrameDelayMs(input.frameDelayMs);
|
||||
const qrVersion = normalizeQRVersion(input.qrVersion);
|
||||
const eccLevel = normalizeEccLevel(input.eccLevel);
|
||||
const parallelCount = normalizeParallelQRCount(input.parallelCount);
|
||||
|
||||
const moduleCount = qrVersion * 4 + 17;
|
||||
|
||||
@@ -61,21 +65,30 @@ function handleGenerate(input: GenerateInput): GifOutput {
|
||||
const quietModules = 8; // 4 on each side
|
||||
const totalModules = moduleCount + quietModules;
|
||||
const scale = Math.max(2, Math.round(targetPx / totalModules));
|
||||
const tileSize = totalModules * scale;
|
||||
const layout = getParallelLayout(parallelCount);
|
||||
|
||||
// ─── Generate QR matrix for each packet ─────────────────────────────────────────
|
||||
const frames: Uint8Array[] = [];
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
const width = tileSize * layout.columns;
|
||||
const height = tileSize * layout.rows;
|
||||
const frameCount = packets.length;
|
||||
|
||||
for (let i = 0; i < packets.length; i++) {
|
||||
const packet = packets[i]!;
|
||||
const matrix = generateQRMatrix(packet, qrVersion, eccLevel);
|
||||
const imageData = rasterizeQR(matrix, scale);
|
||||
if (i === 0) {
|
||||
width = imageData.width;
|
||||
height = imageData.height;
|
||||
for (let frameIndex = 0; frameIndex < frameCount; frameIndex++) {
|
||||
const composite = new Uint8ClampedArray(width * height * 4);
|
||||
composite.fill(255);
|
||||
|
||||
for (let tileIndex = 0; tileIndex < parallelCount; tileIndex++) {
|
||||
const laneOffset = Math.floor(tileIndex * packets.length / parallelCount);
|
||||
const packetIndex = (frameIndex + laneOffset) % packets.length;
|
||||
const matrix = generateQRMatrix(packets[packetIndex]!, qrVersion, eccLevel);
|
||||
const imageData = rasterizeQR(matrix, scale);
|
||||
const x = (tileIndex % layout.columns) * tileSize;
|
||||
const y = Math.floor(tileIndex / layout.columns) * tileSize;
|
||||
blitImageData(composite, width, imageData.data, imageData.width, imageData.height, x, y);
|
||||
}
|
||||
frames.push(new Uint8Array(imageData.data.buffer));
|
||||
|
||||
frames.push(new Uint8Array(composite.buffer));
|
||||
}
|
||||
|
||||
// ─── Create animated GIF ───────────────────────────────────────────────
|
||||
@@ -106,3 +119,30 @@ function normalizeQRVersion(value: number | undefined): number {
|
||||
function normalizeEccLevel(value: EccLevel | undefined): EccLevel {
|
||||
return value ?? ECC_LEVEL;
|
||||
}
|
||||
|
||||
function normalizeParallelQRCount(value: number | undefined): ParallelQRCount {
|
||||
return value === 2 || value === 4 ? value : 1;
|
||||
}
|
||||
|
||||
function getParallelLayout(parallelCount: ParallelQRCount): { columns: number; rows: number } {
|
||||
if (parallelCount === 1) return { columns: 1, rows: 1 };
|
||||
if (parallelCount === 2) return { columns: 2, rows: 1 };
|
||||
return { columns: 2, rows: 2 };
|
||||
}
|
||||
|
||||
function blitImageData(
|
||||
target: Uint8ClampedArray,
|
||||
targetWidth: number,
|
||||
source: Uint8ClampedArray,
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
x: number,
|
||||
y: number,
|
||||
): void {
|
||||
for (let row = 0; row < sourceHeight; row++) {
|
||||
const sourceStart = row * sourceWidth * 4;
|
||||
const sourceEnd = sourceStart + sourceWidth * 4;
|
||||
const targetStart = ((y + row) * targetWidth + x) * 4;
|
||||
target.set(source.subarray(sourceStart, sourceEnd), targetStart);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user