enable Parallel QR

This commit is contained in:
infrost
2026-07-07 12:14:50 +01:00
parent 452a0c5df2
commit f06e26c256
6 changed files with 270 additions and 87 deletions
+11 -30
View File
@@ -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
View File
@@ -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
View File
@@ -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)));
}
+31 -1
View File
@@ -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', () => {
+38 -15
View File
@@ -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
View File
@@ -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);
}
}