diff --git a/src/app/routes/receiver.tsx b/src/app/routes/receiver.tsx
index 09598a4..4fe29ff 100644
--- a/src/app/routes/receiver.tsx
+++ b/src/app/routes/receiver.tsx
@@ -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() {
{/* Corner markers */}
-
-
-
-
+
+
+
+
@@ -711,8 +692,8 @@ export function ReceiverPage() {
{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.'}
{error && ⚠ {error}
}
diff --git a/src/app/routes/sender.tsx b/src/app/routes/sender.tsx
index 783e224..e256e07 100644
--- a/src/app/routes/sender.tsx
+++ b/src/app/routes/sender.tsx
@@ -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;
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(DEFAULT_PARALLEL_QR_COUNT);
const [liveTransfer, setLiveTransfer] = useState(null);
const [gifResult, setGifResult] = useState(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() {
Fast
+
+
+ Parallel QR
+ {parallelQRCount} per tick
+
+
handleParallelQRCountChange((e.target as HTMLSelectElement).value)}
+ >
+ {PARALLEL_QR_COUNTS.map((count) => (
+
+ {count} QR{count === 1 ? '' : 's'} per tick
+
+ ))}
+
+
{liveTransfer ? `V${liveTransfer.version}-${liveTransfer.eccLevel}` : qrProfile.label}
Symbol payload
{liveTransfer ? `${liveTransfer.symbolSize} B/frame` : `${qrProfile.maxPayloadSize} B/frame`}
- Frame count
+ QR packets
{stats.frameCount}
+ Parallel QR
+ {liveTransfer ? `${liveTransfer.parallelCount} per tick` : `${parallelQRCount} per tick`}
Live speed
{frameRateFps} fps ({formatDelayMs(frameDelayMs)} ms)
GIF export speed
@@ -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;
+}
diff --git a/src/core/qr/qr_decode.ts b/src/core/qr/qr_decode.ts
index fdc8617..bef9bd8 100644
--- a/src/core/qr/qr_decode.ts
+++ b/src/core/qr/qr_decode.ts
@@ -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 | null = null;
/**
@@ -40,7 +41,17 @@ let preparePromise: Promise | null = null;
export function decodeQRFromCanvas(
imageData: ImageData,
): Promise {
- 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 {
+ 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 {
+async function decodeImageData(
+ imageData: ImageData,
+ maxSymbols: number,
+): Promise {
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 {
@@ -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)));
+}
diff --git a/src/tests/test_qr_modules.test.ts b/src/tests/test_qr_modules.test.ts
index 6f6f199..9723ebe 100644
--- a/src/tests/test_qr_modules.test.ts
+++ b/src/tests/test_qr_modules.test.ts
@@ -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', () => {
diff --git a/src/workers/decode.worker.ts b/src/workers/decode.worker.ts
index f70d9e8..1acfdda 100644
--- a/src/workers/decode.worker.ts
+++ b/src/workers/decode.worker.ts
@@ -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 {
}
async function handleFrame(imageData: ImageData): Promise {
- 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 {
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 {
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 {
// 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 ────────────────────────
diff --git a/src/workers/gif.worker.ts b/src/workers/gif.worker.ts
index d07525f..eb03a9b 100644
--- a/src/workers/gif.worker.ts
+++ b/src/workers/gif.worker.ts
@@ -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);
+ }
+}