add zxing-wasm qr encode method

This commit is contained in:
infrost
2026-07-07 21:24:22 +01:00
parent 2ad5b606c3
commit af28cd45df
13 changed files with 920 additions and 87 deletions
+286 -45
View File
@@ -2,9 +2,14 @@
* Sender page — text/file input, live QR playback, and GIF export.
*/
import { useState, useCallback, useEffect, useRef } from 'preact/hooks';
import { generateQRMatrix } from '@/core/qr/qr_encode';
import type { EccLevel } from '@/core/qr/qr_encode';
import { rasterizeQR } from '@/core/qr/frame_raster';
import {
DEFAULT_QR_ENCODER,
QR_ENCODERS,
formatQREncoder,
normalizeQREncoder,
type QREncoder,
} from '@/core/qr/qr_encoder_browser';
import {
DEFAULT_QR_ECC_LEVEL,
DEFAULT_QR_VERSION,
@@ -54,17 +59,28 @@ interface LiveTransfer {
rows: number;
version: number;
eccLevel: EccLevel;
qrEncoder: QREncoder;
symbolSize: number;
scale: number;
displayFrameCount: number;
parallelCount: ParallelQRCount;
}
interface RenderedTile {
source: CanvasImageSource;
close?: () => void;
}
interface FrameCache {
frames: Map<number, ImageData>;
frames: Map<number, RenderedTile>;
maxEntries: number;
}
type RenderWorkerMessage =
| { type: 'tile'; requestId: number; packetIndex: number; imageData: ImageData }
| { type: 'done'; requestId: number }
| { type: 'error'; requestId: number; packetIndex?: number; message: string };
// ─── Styles ───────────────────────────────────────────────────────────────────
type CSSProps = Record<string, string | number>;
@@ -77,7 +93,9 @@ const PARALLEL_QR_COUNTS: ParallelQRCount[] = [1, 2, 4];
const FEC_CODEC_OPTIONS: FecCodec[] = ['wasm-raptorq', 'js-rlnc'];
const LIVE_TARGET_PX = 360;
const QR_QUIET_ZONE_MODULES = 4;
const FRAME_CACHE_LIMIT = 120;
const FRAME_CACHE_LIMIT = 240;
const PREFETCH_DISPLAY_FRAMES = 120;
const QR_RENDER_BATCH_LIMIT = 96;
const S = {
section: {
@@ -237,6 +255,7 @@ export function SenderPage() {
const [status, setStatus] = useState('');
const [qrVersion, setQrVersion] = useState<QRVersionOption>(DEFAULT_QR_VERSION);
const [eccLevel, setEccLevel] = useState<EccLevel>(DEFAULT_QR_ECC_LEVEL);
const [qrEncoder, setQrEncoder] = useState<QREncoder>(DEFAULT_QR_ENCODER);
const [fecCodec, setFecCodec] = useState<FecCodec>(DEFAULT_FEC_CODEC);
const [raptorqRepairPercent, setRaptorqRepairPercent] = useState(DEFAULT_RAPTORQ_REPAIR_PERCENT);
const [frameRateFps, setFrameRateFps] = useState(DEFAULT_FRAME_RATE_FPS);
@@ -248,6 +267,7 @@ export function SenderPage() {
preprocessedSize: number;
frameCount: number;
totalGenerations: number;
qrEncoder: QREncoder;
fecCodec: FecCodec;
raptorqRepairPercent: number;
} | null>(null);
@@ -259,12 +279,16 @@ export function SenderPage() {
const liveTransferRef = useRef<LiveTransfer | null>(null);
const encodeWorkerRef = useRef<Worker | null>(null);
const gifWorkerRef = useRef<Worker | null>(null);
const qrRenderWorkerRef = useRef<Worker | null>(null);
const playbackTimerRef = useRef<number | null>(null);
const liveFrameIndexRef = useRef(0);
const frameRateFpsRef = useRef(DEFAULT_FRAME_RATE_FPS);
const frameCacheRef = useRef<FrameCache>(createFrameCache());
const requestedPacketImagesRef = useRef<Set<number>>(new Set());
const playbackStartedRef = useRef(false);
const runIdRef = useRef(0);
const qrProfile = createQRTransferProfile(qrVersion, eccLevel);
const qrRenderRequestIdRef = useRef(0);
const qrProfile = createQRTransferProfile(qrVersion, eccLevel, qrEncoder);
const frameDelayMs = frameRateToDelayMs(frameRateFps);
const clearPlaybackTimer = useCallback(() => {
@@ -284,6 +308,61 @@ export function SenderPage() {
gifWorkerRef.current = null;
}, []);
const terminateQRRenderWorker = useCallback(() => {
qrRenderRequestIdRef.current++;
qrRenderWorkerRef.current?.terminate();
qrRenderWorkerRef.current = null;
}, []);
const requestRenderWindow = useCallback((
transfer: LiveTransfer,
startFrameIndex: number,
) => {
const worker = qrRenderWorkerRef.current;
if (!worker) return;
const missingPacketIndexes: number[] = [];
const endFrameIndex = startFrameIndex + PREFETCH_DISPLAY_FRAMES;
for (let frameIndex = startFrameIndex; frameIndex < endFrameIndex; frameIndex++) {
const normalizedFrameIndex = frameIndex % transfer.displayFrameCount;
for (let tileIndex = 0; tileIndex < transfer.parallelCount; tileIndex++) {
const packetIndex = getPacketIndexForDisplayFrame(
transfer,
normalizedFrameIndex,
tileIndex,
);
if (packetIndex === null) continue;
if (frameCacheRef.current.frames.has(packetIndex)) continue;
if (requestedPacketImagesRef.current.has(packetIndex)) continue;
requestedPacketImagesRef.current.add(packetIndex);
missingPacketIndexes.push(packetIndex);
if (missingPacketIndexes.length >= QR_RENDER_BATCH_LIMIT) {
break;
}
}
if (missingPacketIndexes.length >= QR_RENDER_BATCH_LIMIT) {
break;
}
}
if (missingPacketIndexes.length === 0) return;
const requestId = qrRenderRequestIdRef.current;
worker.postMessage({
type: 'renderTiles',
requestId,
qrVersion: transfer.version,
eccLevel: transfer.eccLevel,
qrEncoder: transfer.qrEncoder,
tiles: missingPacketIndexes.map((packetIndex) => ({
packetIndex,
packet: transfer.packets[packetIndex]!,
})),
});
}, []);
const scheduleNextLiveFrame = useCallback(() => {
clearPlaybackTimer();
const delayMs = frameRateToDelayMs(frameRateFpsRef.current);
@@ -295,7 +374,14 @@ export function SenderPage() {
try {
const frameIndex = liveFrameIndexRef.current % transfer.displayFrameCount;
requestRenderWindow(transfer, frameIndex);
if (!isDisplayFrameReady(transfer, frameIndex, frameCacheRef.current)) {
scheduleNextLiveFrame();
return;
}
drawLiveFrame(canvas, transfer, frameIndex, frameCacheRef.current);
requestRenderWindow(transfer, frameIndex + 1);
liveFrameIndexRef.current = (frameIndex + 1) % transfer.displayFrameCount;
scheduleNextLiveFrame();
} catch (err: any) {
@@ -303,7 +389,7 @@ export function SenderPage() {
setError(err.message ?? String(err));
}
}, delayMs);
}, [clearPlaybackTimer]);
}, [clearPlaybackTimer, requestRenderWindow]);
const startLivePlayback = useCallback((resetIndex: boolean) => {
clearPlaybackTimer();
@@ -317,10 +403,17 @@ export function SenderPage() {
}
const frameIndex = liveFrameIndexRef.current % transfer.displayFrameCount;
requestRenderWindow(transfer, frameIndex);
if (!isDisplayFrameReady(transfer, frameIndex, frameCacheRef.current)) {
scheduleNextLiveFrame();
return;
}
drawLiveFrame(canvas, transfer, frameIndex, frameCacheRef.current);
requestRenderWindow(transfer, frameIndex + 1);
liveFrameIndexRef.current = (frameIndex + 1) % transfer.displayFrameCount;
scheduleNextLiveFrame();
}, [clearPlaybackTimer, scheduleNextLiveFrame]);
}, [clearPlaybackTimer, requestRenderWindow, scheduleNextLiveFrame]);
useEffect(() => {
frameRateFpsRef.current = frameRateFps;
@@ -336,26 +429,100 @@ export function SenderPage() {
useEffect(() => {
liveTransferRef.current = liveTransfer;
frameCacheRef.current.frames.clear();
clearFrameCache(frameCacheRef.current);
requestedPacketImagesRef.current.clear();
playbackStartedRef.current = false;
clearPlaybackTimer();
terminateQRRenderWorker();
if (liveTransfer) {
startLivePlayback(true);
} else {
clearPlaybackTimer();
if (!liveTransfer) {
return clearPlaybackTimer;
}
return clearPlaybackTimer;
}, [liveTransfer, clearPlaybackTimer, startLivePlayback]);
const requestId = ++qrRenderRequestIdRef.current;
const worker = new Worker(
new URL('@/workers/qr_render.worker.ts', import.meta.url),
{ type: 'module' },
);
qrRenderWorkerRef.current = worker;
setStatus('Rendering first QR frame…');
worker.onmessage = (e: MessageEvent<RenderWorkerMessage>) => {
const msg = e.data;
if (msg.requestId !== qrRenderRequestIdRef.current) return;
if (msg.type === 'tile') {
void (async () => {
try {
const renderedTile = await prepareRenderedTile(msg.imageData);
if (msg.requestId !== qrRenderRequestIdRef.current) {
closeRenderedTile(renderedTile);
return;
}
requestedPacketImagesRef.current.delete(msg.packetIndex);
cacheRenderedTile(frameCacheRef.current, msg.packetIndex, renderedTile);
trimFrameCache(frameCacheRef.current);
if (!playbackStartedRef.current && isDisplayFrameReady(liveTransfer, 0, frameCacheRef.current)) {
playbackStartedRef.current = true;
setStatus(
`Live QR running (${liveTransfer.packets.length} packets, ` +
`${liveTransfer.parallelCount} per tick).`,
);
startLivePlayback(true);
}
} catch (err: any) {
requestedPacketImagesRef.current.delete(msg.packetIndex);
setError(err.message ?? String(err));
}
})();
return;
}
if (msg.type === 'error') {
requestedPacketImagesRef.current.delete(msg.packetIndex ?? -1);
setError(msg.message);
}
};
worker.onerror = (err) => {
setError(err.message || 'QR render worker failed.');
};
requestRenderWindow(liveTransfer, 0);
return () => {
qrRenderRequestIdRef.current++;
clearPlaybackTimer();
if (qrRenderWorkerRef.current === worker) {
qrRenderWorkerRef.current = null;
}
worker.terminate();
requestedPacketImagesRef.current.clear();
playbackStartedRef.current = false;
clearFrameCache(frameCacheRef.current);
};
}, [
liveTransfer,
clearPlaybackTimer,
requestRenderWindow,
startLivePlayback,
terminateQRRenderWorker,
]);
/** Wipe all output state and stop any live playback loop. */
const resetOutput = useCallback(() => {
runIdRef.current++;
terminateEncodeWorker();
terminateGifWorker();
terminateQRRenderWorker();
clearPlaybackTimer();
liveTransferRef.current = null;
liveFrameIndexRef.current = 0;
frameCacheRef.current.frames.clear();
clearFrameCache(frameCacheRef.current);
requestedPacketImagesRef.current.clear();
playbackStartedRef.current = false;
setLiveTransfer(null);
setGifResult(null);
setStats(null);
@@ -363,7 +530,7 @@ export function SenderPage() {
setPreparingGif(false);
setError('');
setStatus('');
}, [clearPlaybackTimer, terminateEncodeWorker, terminateGifWorker]);
}, [clearPlaybackTimer, terminateEncodeWorker, terminateGifWorker, terminateQRRenderWorker]);
const handleStopTransfer = useCallback(() => {
resetOutput();
@@ -399,6 +566,11 @@ export function SenderPage() {
resetOutput();
}, [resetOutput]);
const handleQREncoderChange = useCallback((value: string) => {
setQrEncoder(normalizeQREncoder(value));
resetOutput();
}, [resetOutput]);
const handleFecCodecChange = useCallback((value: string) => {
setFecCodec(normalizeFecCodec(value));
resetOutput();
@@ -443,7 +615,7 @@ export function SenderPage() {
}
const compress = data.byteLength > 64;
const selectedQRProfile = createQRTransferProfile(qrVersion, eccLevel);
const selectedQRProfile = createQRTransferProfile(qrVersion, eccLevel, qrEncoder);
setEncodingLive(true);
setStatus('Encoding live QR…');
@@ -495,18 +667,19 @@ export function SenderPage() {
preprocessedSize: encoded.stats.preprocessedSize,
frameCount: encoded.stats.frameCount,
totalGenerations: encoded.totalGenerations,
qrEncoder,
fecCodec,
raptorqRepairPercent,
});
const nextLiveTransfer = createLiveTransfer(
encoded.packets,
selectedQRProfile,
qrEncoder,
parallelQRCount,
);
setLiveTransfer(nextLiveTransfer);
setStatus(
`Live QR running (${encoded.stats.frameCount} packets, ` +
`${nextLiveTransfer.parallelCount} per tick).`,
`Encoded ${encoded.stats.frameCount} packets. Rendering first QR frame…`,
);
} catch (err: any) {
if (runId === runIdRef.current) {
@@ -523,7 +696,7 @@ export function SenderPage() {
setEncodingLive(false);
}
}
}, [mode, text, file, resetOutput, qrVersion, eccLevel, fecCodec, raptorqRepairPercent, parallelQRCount]);
}, [mode, text, file, resetOutput, qrVersion, eccLevel, qrEncoder, fecCodec, raptorqRepairPercent, parallelQRCount]);
const handlePrepareGif = useCallback(async () => {
const transfer = liveTransferRef.current;
@@ -575,6 +748,7 @@ export function SenderPage() {
frameDelayMs: outputFrameDelayMs,
qrVersion: transfer.version,
eccLevel: transfer.eccLevel,
qrEncoder: transfer.qrEncoder,
parallelCount: transfer.parallelCount,
},
);
@@ -635,6 +809,12 @@ export function SenderPage() {
return (
<div>
<style>{`
.qr-live-canvas {
image-rendering: -moz-crisp-edges;
image-rendering: pixelated;
}
`}</style>
{/* ── Input mode toggle ───────────────────────────────────────────────── */}
<div style={S.section}>
<div style={S.row}>
@@ -673,7 +853,7 @@ export function SenderPage() {
onChange={(e) => handleQRVersionChange((e.target as HTMLSelectElement).value)}
>
{QR_VERSION_OPTIONS.map((version) => {
const profile = createQRTransferProfile(version, eccLevel);
const profile = createQRTransferProfile(version, eccLevel, qrEncoder);
return (
<option key={version} value={version}>
V{version} · {profile.maxPayloadSize} B/frame
@@ -694,7 +874,7 @@ export function SenderPage() {
onChange={(e) => handleEccLevelChange((e.target as HTMLSelectElement).value)}
>
{ECC_LEVEL_OPTIONS.map((level) => {
const profile = createQRTransferProfile(qrVersion, level);
const profile = createQRTransferProfile(qrVersion, level, qrEncoder);
return (
<option key={level} value={level}>
{formatEccLevel(level)} · {profile.maxPayloadSize} B/frame
@@ -703,6 +883,24 @@ export function SenderPage() {
})}
</select>
</div>
<div style={{ marginBottom: 16 }}>
<div style={{ ...S.row, justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={S.label}>QR encoder</span>
<span style={S.infoValue}>{formatQREncoder(qrEncoder)}</span>
</div>
<select
value={qrEncoder}
style={S.select}
disabled={encodingLive}
onChange={(e) => handleQREncoderChange((e.target as HTMLSelectElement).value)}
>
{QR_ENCODERS.map((encoder) => (
<option key={encoder} value={encoder}>
{formatQREncoder(encoder)}
</option>
))}
</select>
</div>
<div style={{ marginBottom: 16 }}>
<div style={{ ...S.row, justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={S.label}>FEC codec</span>
@@ -811,6 +1009,7 @@ export function SenderPage() {
<div style={S.label}>Live QR Transfer</div>
<div ref={qrStageRef} style={S.qrStage(fullscreenActive)}>
<canvas
className="qr-live-canvas"
ref={canvasRef}
width={liveTransfer.width}
height={liveTransfer.height}
@@ -859,6 +1058,8 @@ export function SenderPage() {
<span style={S.infoValue}>{formatBytes(stats.preprocessedSize)}</span>
<span style={S.infoLabel}>QR size</span>
<span style={S.infoValue}>{liveTransfer ? `V${liveTransfer.version}-${liveTransfer.eccLevel}` : qrProfile.label}</span>
<span style={S.infoLabel}>QR encoder</span>
<span style={S.infoValue}>{formatQREncoder(stats.qrEncoder)}</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}>FEC codec</span>
@@ -943,9 +1144,51 @@ function createFrameCache(): FrameCache {
return { frames: new Map(), maxEntries: FRAME_CACHE_LIMIT };
}
async function prepareRenderedTile(imageData: ImageData): Promise<RenderedTile> {
if (typeof createImageBitmap === 'function') {
const bitmap = await createImageBitmap(imageData);
return {
source: bitmap,
close: () => bitmap.close(),
};
}
const canvas = document.createElement('canvas');
canvas.width = imageData.width;
canvas.height = imageData.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Canvas 2D context is unavailable for QR tile cache.');
}
ctx.putImageData(imageData, 0, 0);
return { source: canvas };
}
function cacheRenderedTile(
cache: FrameCache,
packetIndex: number,
tile: RenderedTile,
): void {
const previous = cache.frames.get(packetIndex);
if (previous) closeRenderedTile(previous);
cache.frames.set(packetIndex, tile);
}
function closeRenderedTile(tile: RenderedTile): void {
tile.close?.();
}
function clearFrameCache(cache: FrameCache): void {
for (const tile of cache.frames.values()) {
closeRenderedTile(tile);
}
cache.frames.clear();
}
function createLiveTransfer(
packets: Uint8Array[],
profile: QRTransferProfile,
qrEncoder: QREncoder,
parallelCount: ParallelQRCount,
): LiveTransfer {
if (packets.length === 0) {
@@ -968,6 +1211,7 @@ function createLiveTransfer(
rows: layout.rows,
version: profile.version,
eccLevel: profile.eccLevel,
qrEncoder,
symbolSize: profile.maxPayloadSize,
scale,
displayFrameCount: stripedFrameCount(packets.length, parallelCount),
@@ -994,39 +1238,36 @@ function drawLiveFrame(
for (let tileIndex = 0; tileIndex < transfer.parallelCount; tileIndex++) {
const packetIndex = getPacketIndexForDisplayFrame(transfer, frameIndex, tileIndex);
if (packetIndex === null) continue;
const image = getLivePacketImage(transfer, packetIndex, cache);
const image = cache.frames.get(packetIndex);
if (!image) continue;
const x = (tileIndex % transfer.columns) * transfer.tileWidth;
const y = Math.floor(tileIndex / transfer.columns) * transfer.tileHeight;
ctx.putImageData(image, x, y);
ctx.drawImage(image.source, x, y, transfer.tileWidth, transfer.tileHeight);
}
}
function getLivePacketImage(
function isDisplayFrameReady(
transfer: LiveTransfer,
packetIndex: number,
frameIndex: number,
cache: FrameCache,
): ImageData {
const cacheKey = packetIndex % transfer.packets.length;
const cached = cache.frames.get(cacheKey);
if (cached) return cached;
const packet = transfer.packets[cacheKey];
if (!packet) {
throw new Error(`Missing QR packet at frame ${cacheKey}.`);
}
const matrix = generateQRMatrix(packet, transfer.version, transfer.eccLevel);
const image = rasterizeQR(matrix, transfer.scale);
cache.frames.set(cacheKey, image);
if (cache.frames.size > cache.maxEntries) {
const oldestKey = cache.frames.keys().next().value;
if (oldestKey !== undefined) {
cache.frames.delete(oldestKey);
): boolean {
for (let tileIndex = 0; tileIndex < transfer.parallelCount; tileIndex++) {
const packetIndex = getPacketIndexForDisplayFrame(transfer, frameIndex, tileIndex);
if (packetIndex !== null && !cache.frames.has(packetIndex)) {
return false;
}
}
return true;
}
return image;
function trimFrameCache(cache: FrameCache): void {
while (cache.frames.size > cache.maxEntries) {
const oldestKey = cache.frames.keys().next().value;
if (oldestKey === undefined) return;
const oldestTile = cache.frames.get(oldestKey);
if (oldestTile) closeRenderedTile(oldestTile);
cache.frames.delete(oldestKey);
}
}
function getParallelLayout(parallelCount: ParallelQRCount): { columns: number; rows: number } {
+12 -4
View File
@@ -17,7 +17,7 @@
import { readFileSync, existsSync, openSync, closeSync } from 'fs';
import { ReadStream } from 'tty';
import { generateQRMatrix } from '../core/qr/qr_encode';
import { COMPATIBLE_QR_ENCODER, encodeQRCodeMatrix } from '../core/qr/qr_encoder';
import { packetize } from '../core/sender/packetizer';
import { scheduleFrames } from '../core/sender/scheduler';
import { QR_VERSION, ECC_LEVEL } from '../core/protocol/constants';
@@ -158,7 +158,7 @@ function buildFrames(
// Main loop
// ─────────────────────────────────────────────────────────────────────────────────
function main() {
async function main() {
const args = process.argv.slice(2);
if (args.includes('-h') || args.includes('--help')) {
@@ -228,7 +228,12 @@ function main() {
// Pre-render all QR matrices to terminal strings
const frames: string[][] = [];
for (const pkt of packets) {
const matrix = generateQRMatrix(pkt, QR_VERSION, ECC_LEVEL);
const matrix = await encodeQRCodeMatrix(
pkt,
QR_VERSION,
ECC_LEVEL,
COMPATIBLE_QR_ENCODER,
);
frames.push(renderToTerminal(matrix));
}
@@ -309,4 +314,7 @@ function main() {
const interval = setInterval(draw, FPS_MS);
}
main();
main().catch((err: any) => {
console.error(`Error: ${err.message ?? String(err)}`);
process.exit(1);
});
+16 -3
View File
@@ -7,13 +7,19 @@
*/
import { CRC32C_SIZE, HEADER_SIZE } from './constants';
import { getMaxByteCapacity, type EccLevel } from '@/core/qr/qr_encode';
import {
getMaxByteCapacity,
getMaxZXingWriterByteCapacity,
type EccLevel,
} from '@/core/qr/qr_encode';
import type { QREncoder } from '@/core/qr/qr_encoder';
export interface QRTransferProfile {
id: string;
label: string;
version: number;
eccLevel: EccLevel;
qrEncoder: QREncoder;
maxPacketSize: number;
maxPayloadSize: number;
}
@@ -40,16 +46,23 @@ export const QR_TRANSFER_PROFILES: QRTransferProfile[] = PROFILE_SPECS.map((spec
createQRTransferProfile(spec.version, spec.eccLevel),
);
export function createQRTransferProfile(version: number, eccLevel: EccLevel): QRTransferProfile {
export function createQRTransferProfile(
version: number,
eccLevel: EccLevel,
qrEncoder: QREncoder = 'zxing-wasm',
): QRTransferProfile {
const normalizedVersion = normalizeQRVersion(version);
const normalizedEccLevel = normalizeEccLevel(eccLevel);
const maxPacketSize = getMaxByteCapacity(normalizedVersion, normalizedEccLevel);
const maxPacketSize = qrEncoder === 'js-qrcode'
? getMaxByteCapacity(normalizedVersion, normalizedEccLevel)
: getMaxZXingWriterByteCapacity(normalizedVersion, normalizedEccLevel);
const maxPayloadSize = maxPacketSize - HEADER_SIZE - CRC32C_SIZE;
return {
id: profileId(normalizedVersion, normalizedEccLevel),
label: `V${normalizedVersion}-${normalizedEccLevel}`,
version: normalizedVersion,
eccLevel: normalizedEccLevel,
qrEncoder,
maxPacketSize,
maxPayloadSize,
};
+43 -16
View File
@@ -109,6 +109,7 @@ const RS_BLOCK_TABLE: number[][] = [
// ---------------------------------------------------------------------------
const ECC_INDEX: Record<EccLevel, number> = { L: 0, M: 1, Q: 2, H: 3 };
const ZXING_UINT8_ECI_OVERHEAD_BITS = 20;
/**
* Returns the maximum number of data bytes that can be stored in a QR code
@@ -118,23 +119,23 @@ const ECC_INDEX: Record<EccLevel, number> = { L: 0, M: 1, Q: 2, H: 3 };
* Overhead = 4 bits (mode indicator) + character-count bits (8 for v1-9, 16 for v10-40).
*/
export function getMaxByteCapacity(version: number, eccLevel: EccLevel): number {
const idx = (version - 1) * 4 + ECC_INDEX[eccLevel];
const entry = RS_BLOCK_TABLE[idx];
return getMaxByteCapacityWithExtraOverhead(version, eccLevel, 0);
}
if (!entry) {
throw new Error(`No RS block table entry for V${version}-${eccLevel}`);
}
// Sum data codewords across all block groups
let totalDataCodewords = 0;
for (let i = 0; i < entry.length; i += 3) {
totalDataCodewords += entry[i] * entry[i + 2];
}
const charCountBits = version <= 9 ? 8 : 16;
const overheadBits = 4 + charCountBits;
return Math.floor((totalDataCodewords * 8 - overheadBits) / 8);
/**
* Maximum raw bytes accepted by `zxing-wasm/writer` when the input is a
* Uint8Array. ZXing emits a binary ECI segment for byte input, which consumes
* two extra QR codewords versus the legacy qrcode-generator Byte-mode path.
*/
export function getMaxZXingWriterByteCapacity(
version: number,
eccLevel: EccLevel,
): number {
return getMaxByteCapacityWithExtraOverhead(
version,
eccLevel,
ZXING_UINT8_ECI_OVERHEAD_BITS,
);
}
/**
@@ -152,6 +153,32 @@ export function getMinVersion(dataLength: number, eccLevel: EccLevel): number {
);
}
function getMaxByteCapacityWithExtraOverhead(
version: number,
eccLevel: EccLevel,
extraOverheadBits: number,
): number {
const totalDataCodewords = getTotalDataCodewords(version, eccLevel);
const charCountBits = version <= 9 ? 8 : 16;
const overheadBits = 4 + charCountBits + extraOverheadBits;
return Math.max(0, Math.floor((totalDataCodewords * 8 - overheadBits) / 8));
}
function getTotalDataCodewords(version: number, eccLevel: EccLevel): number {
const idx = (version - 1) * 4 + ECC_INDEX[eccLevel];
const entry = RS_BLOCK_TABLE[idx];
if (!entry) {
throw new Error(`No RS block table entry for V${version}-${eccLevel}`);
}
let totalDataCodewords = 0;
for (let i = 0; i < entry.length; i += 3) {
totalDataCodewords += entry[i] * entry[i + 2];
}
return totalDataCodewords;
}
// ---------------------------------------------------------------------------
// QR generation
// ---------------------------------------------------------------------------
+73
View File
@@ -0,0 +1,73 @@
/**
* QR symbol encoder facade.
*
* The transfer protocol is independent from the library used to turn packet
* bytes into a QR symbol. Keep that choice explicit so browser workers, tests,
* and the CLI do not accidentally drift onto different encoder paths.
*/
import { rasterizeQR } from './frame_raster';
import { generateQRMatrix, type EccLevel } from './qr_encode';
export const QR_ENCODERS = ['zxing-wasm', 'js-qrcode'] as const;
export type QREncoder = typeof QR_ENCODERS[number];
export const DEFAULT_QR_ENCODER: QREncoder = 'zxing-wasm';
export const COMPATIBLE_QR_ENCODER: QREncoder = 'js-qrcode';
export function normalizeQREncoder(value: unknown): QREncoder {
return QR_ENCODERS.includes(value as QREncoder)
? value as QREncoder
: DEFAULT_QR_ENCODER;
}
export function formatQREncoder(encoder: QREncoder): string {
switch (encoder) {
case 'zxing-wasm':
return 'ZXing WASM';
case 'js-qrcode':
return 'JS QR';
}
}
export function encodeQRCodeMatrixWithJS(
data: Uint8Array,
version: number,
eccLevel: EccLevel,
): boolean[][] {
return generateQRMatrix(data, version, eccLevel);
}
export function renderQRCodeImageDataWithJS(
data: Uint8Array,
version: number,
eccLevel: EccLevel,
scale: number,
): ImageData {
return rasterizeQR(generateQRMatrix(data, version, eccLevel), scale);
}
export async function encodeQRCodeMatrix(
data: Uint8Array,
version: number,
eccLevel: EccLevel,
encoder: QREncoder = COMPATIBLE_QR_ENCODER,
): Promise<boolean[][]> {
if (encoder !== 'js-qrcode') {
throw new Error('ZXing WASM QR encoder is only available through qr_encoder_browser.');
}
return encodeQRCodeMatrixWithJS(data, version, eccLevel);
}
export async function renderQRCodeImageData(
data: Uint8Array,
version: number,
eccLevel: EccLevel,
scale: number,
encoder: QREncoder = COMPATIBLE_QR_ENCODER,
): Promise<ImageData> {
if (encoder !== 'js-qrcode') {
throw new Error('ZXing WASM QR encoder is only available through qr_encoder_browser.');
}
return renderQRCodeImageDataWithJS(data, version, eccLevel, scale);
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Browser QR encoder facade.
*
* This file is allowed to import the ZXing WASM writer. Node/CLI code should
* import `qr_encoder.ts` so esbuild does not pull browser-only wasm assets into
* the terminal bundle.
*/
import {
type QREncoder,
encodeQRCodeMatrixWithJS,
renderQRCodeImageDataWithJS,
} from './qr_encoder';
import type { EccLevel } from './qr_encode';
import {
generateQRMatrixWithZXing,
renderQRCodeImageDataWithZXing,
} from './qr_write_wasm';
export * from './qr_encoder';
export async function encodeQRCodeMatrix(
data: Uint8Array,
version: number,
eccLevel: EccLevel,
encoder: QREncoder = 'zxing-wasm',
): Promise<boolean[][]> {
switch (encoder) {
case 'js-qrcode':
return encodeQRCodeMatrixWithJS(data, version, eccLevel);
case 'zxing-wasm':
return generateQRMatrixWithZXing(data, version, eccLevel);
}
}
export async function renderQRCodeImageData(
data: Uint8Array,
version: number,
eccLevel: EccLevel,
scale: number,
encoder: QREncoder = 'zxing-wasm',
): Promise<ImageData> {
switch (encoder) {
case 'js-qrcode':
return renderQRCodeImageDataWithJS(data, version, eccLevel, scale);
case 'zxing-wasm':
return renderQRCodeImageDataWithZXing(data, version, eccLevel, scale);
}
}
+222
View File
@@ -0,0 +1,222 @@
/**
* QR code writer backed by ZXing-C++ WebAssembly.
*
* The wasm file is loaded from the bundled local asset, mirroring the reader
* wrapper. This path is intended for high-throughput sender rendering where
* generating QR symbols on the UI thread is too expensive.
*/
import {
prepareZXingModule,
writeBarcode,
type BarcodeSymbol,
type WriterOptions,
} from 'zxing-wasm/writer';
import zxingWriterWasmUrl from 'zxing-wasm/writer/zxing_writer.wasm?url';
import {
getMaxZXingWriterByteCapacity,
type EccLevel,
} from '@/core/qr/qr_encode';
let preparePromise: Promise<unknown> | null = null;
export async function renderQRCodeImageDataWithZXing(
data: Uint8Array,
version: number,
eccLevel: EccLevel,
scale: number,
): Promise<ImageData> {
const symbol = await writeQRCodeSymbolWithZXing(data, version, eccLevel, scale);
const moduleCount = version * 4 + 17;
if (symbol.width === moduleCount && symbol.height === moduleCount) {
return rasterizeSymbolModules(symbol, scale);
}
const expectedSize = expectedQRPixelSize(version, scale);
if (symbol.width === expectedSize && symbol.height === expectedSize) {
return symbolPixelsToImageData(symbol);
}
throw new Error(
`ZXing QR writer returned ${symbol.width}x${symbol.height}, ` +
`expected ${moduleCount}x${moduleCount} modules or ${expectedSize}x${expectedSize} pixels ` +
`for V${version}-${eccLevel} at scale ${scale}.`,
);
}
export async function generateQRMatrixWithZXing(
data: Uint8Array,
version: number,
eccLevel: EccLevel,
): Promise<boolean[][]> {
const symbol = await writeQRCodeSymbolWithZXing(data, version, eccLevel, 1);
return symbolToMatrix(symbol, version, eccLevel);
}
export async function writeQRCodeSymbolWithZXing(
data: Uint8Array,
version: number,
eccLevel: EccLevel,
scale: number,
): Promise<BarcodeSymbol> {
validateQRCodeRenderOptions(version, eccLevel, scale, data.length);
await prepareWriter();
const options: WriterOptions = {
format: 'QRCode',
options: `version=${version},ecLevel=${eccLevel}`,
scale,
addQuietZones: true,
addHRT: false,
};
const result = await writeBarcode(data, options);
if (result.error) {
throw new Error(`ZXing QR writer failed: ${result.error}`);
}
return result.symbol;
}
function prepareWriter(): Promise<unknown> {
if (!preparePromise) {
preparePromise = Promise.resolve(
prepareZXingModule({
overrides: {
locateFile: (path) => path.endsWith('.wasm') ? zxingWriterWasmUrl : path,
},
equalityFn: Object.is,
fireImmediately: true,
}),
);
}
return preparePromise;
}
function symbolToMatrix(
symbol: BarcodeSymbol,
version: number,
eccLevel: EccLevel,
): boolean[][] {
if (symbol.data.length !== symbol.width * symbol.height) {
throw new Error(
`ZXing QR symbol buffer size mismatch: ${symbol.data.length} bytes for ` +
`${symbol.width}x${symbol.height}.`,
);
}
const moduleCount = version * 4 + 17;
let offset = 0;
if (symbol.width === moduleCount && symbol.height === moduleCount) {
offset = 0;
} else if (
symbol.width === moduleCount + 8 &&
symbol.height === moduleCount + 8
) {
offset = 4;
} else {
throw new Error(
`ZXing QR writer returned ${symbol.width}x${symbol.height}, ` +
`expected ${moduleCount}x${moduleCount} modules for V${version}-${eccLevel}.`,
);
}
const matrix: boolean[][] = [];
for (let y = 0; y < moduleCount; y++) {
const row: boolean[] = [];
for (let x = 0; x < moduleCount; x++) {
row.push(symbol.data[(y + offset) * symbol.width + x + offset] === 0);
}
matrix.push(row);
}
return matrix;
}
function symbolPixelsToImageData(symbol: BarcodeSymbol): ImageData {
if (symbol.data.length !== symbol.width * symbol.height) {
throw new Error(
`ZXing QR symbol buffer size mismatch: ${symbol.data.length} bytes for ` +
`${symbol.width}x${symbol.height}.`,
);
}
const rgba = new Uint8ClampedArray(symbol.width * symbol.height * 4);
for (let i = 0; i < symbol.data.length; i++) {
const value = symbol.data[i] === 0 ? 0 : 255;
const offset = i * 4;
rgba[offset] = value;
rgba[offset + 1] = value;
rgba[offset + 2] = value;
rgba[offset + 3] = 255;
}
return new ImageData(rgba, symbol.width, symbol.height);
}
function rasterizeSymbolModules(symbol: BarcodeSymbol, scale: number): ImageData {
if (symbol.data.length !== symbol.width * symbol.height) {
throw new Error(
`ZXing QR symbol buffer size mismatch: ${symbol.data.length} bytes for ` +
`${symbol.width}x${symbol.height}.`,
);
}
const quietZoneModules = 4;
const pixelSize = (symbol.width + quietZoneModules * 2) * scale;
const rgba = new Uint8ClampedArray(pixelSize * pixelSize * 4);
rgba.fill(255);
for (let moduleY = 0; moduleY < symbol.height; moduleY++) {
for (let moduleX = 0; moduleX < symbol.width; moduleX++) {
if (symbol.data[moduleY * symbol.width + moduleX] !== 0) continue;
const pixelX = (moduleX + quietZoneModules) * scale;
const pixelY = (moduleY + quietZoneModules) * scale;
for (let y = 0; y < scale; y++) {
const rowStart = ((pixelY + y) * pixelSize + pixelX) * 4;
for (let x = 0; x < scale; x++) {
const offset = rowStart + x * 4;
rgba[offset] = 0;
rgba[offset + 1] = 0;
rgba[offset + 2] = 0;
rgba[offset + 3] = 255;
}
}
}
}
return new ImageData(rgba, pixelSize, pixelSize);
}
function validateQRCodeRenderOptions(
version: number,
eccLevel: EccLevel,
scale: number,
dataLength?: number,
): void {
if (!Number.isInteger(version) || version < 1 || version > 40) {
throw new RangeError(`Invalid QR version: ${version}. Must be 1-40.`);
}
if (eccLevel !== 'L' && eccLevel !== 'M' && eccLevel !== 'Q' && eccLevel !== 'H') {
throw new RangeError(`Invalid QR ECC level: ${eccLevel}.`);
}
if (!Number.isInteger(scale) || scale < 1) {
throw new RangeError(`Invalid QR render scale: ${scale}.`);
}
if (dataLength !== undefined) {
const maxBytes = getMaxZXingWriterByteCapacity(version, eccLevel);
if (dataLength > maxBytes) {
throw new Error(
`Data too large for ZXing QR writer V${version}-${eccLevel}. ` +
`Maximum ${maxBytes} bytes for binary Uint8Array payload, got ${dataLength}.`,
);
}
}
}
function expectedQRPixelSize(version: number, scale: number): number {
const moduleCount = version * 4 + 17;
const quietZoneModules = 8;
return (moduleCount + quietZoneModules) * scale;
}
+4 -4
View File
@@ -190,7 +190,7 @@ describe('CLI Encoder Pipeline', () => {
it('should produce the same frames as the web app (reuse common logic)', async () => {
const { packetize } = await import('@/core/sender/packetizer');
const { scheduleFrames } = await import('@/core/sender/scheduler');
const { generateQRMatrix } = await import('@/core/qr/qr_encode');
const { COMPATIBLE_QR_ENCODER, encodeQRCodeMatrix } = await import('@/core/qr/qr_encoder');
const { QR_VERSION, ECC_LEVEL } = await import('@/core/protocol/constants');
const { parseHeader } = await import('@/core/protocol/packet');
@@ -200,7 +200,7 @@ describe('CLI Encoder Pipeline', () => {
const genIndices = ordered.map((pkt) => parseHeader(pkt).generationIndex);
for (const pkt of ordered) {
const matrix = generateQRMatrix(pkt, QR_VERSION, ECC_LEVEL);
const matrix = await encodeQRCodeMatrix(pkt, QR_VERSION, ECC_LEVEL, COMPATIBLE_QR_ENCODER);
expect(matrix.length).toBe(57);
expect(matrix[0]!.length).toBe(57);
}
@@ -236,7 +236,7 @@ describe('CLI Frame Cycle', () => {
it('should generate valid QR matrix for every scheduled frame', async () => {
const { packetize } = await import('@/core/sender/packetizer');
const { scheduleFrames } = await import('@/core/sender/scheduler');
const { generateQRMatrix } = await import('@/core/qr/qr_encode');
const { COMPATIBLE_QR_ENCODER, encodeQRCodeMatrix } = await import('@/core/qr/qr_encoder');
const { QR_VERSION, ECC_LEVEL } = await import('@/core/protocol/constants');
const data = new TextEncoder().encode('Every frame QR test — medium payload');
@@ -244,7 +244,7 @@ describe('CLI Frame Cycle', () => {
const ordered = scheduleFrames(result.packets, result.totalGenerations);
for (const pkt of ordered) {
const matrix = generateQRMatrix(pkt, QR_VERSION, ECC_LEVEL);
const matrix = await encodeQRCodeMatrix(pkt, QR_VERSION, ECC_LEVEL, COMPATIBLE_QR_ENCODER);
expect(matrix.length).toBe(57);
expect(matrix[0]!.length).toBe(57);
const hasDark = matrix.some((row) => row.some((cell) => cell));
+1 -1
View File
@@ -72,7 +72,7 @@ describe('RaptorQ WASM loader', () => {
}
});
it.runIf(process.env.QR_STREAM_TEST_RAPTORQ_WASM === '1')(
it(
'should round-trip through generated WASM artifacts',
async () => {
const original = new TextEncoder().encode('raptorq wasm roundtrip');
+33
View File
@@ -42,6 +42,21 @@ const zxingReaderWasmPath = join(
'reader',
'zxing_reader.wasm',
);
const zxingWriterWasmPath = join(
process.cwd(),
'node_modules',
'zxing-wasm',
'dist',
'writer',
'zxing_writer.wasm',
);
const raptorqWasmPath = join(
process.cwd(),
'src',
'raptorq',
'wasm',
'qrstream_raptorq_wasm_bg.wasm',
);
// happy-dom's Response is not accepted by Node's instantiateStreaming; tests can
// use the same local wasm bytes through the ArrayBuffer fallback.
@@ -64,6 +79,24 @@ globalThis.fetch = async (input, init) => {
});
}
if (url.includes('zxing_writer.wasm')) {
const bytes = await readFile(zxingWriterWasmPath);
const body = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
return new Response(body, {
headers: { 'Content-Type': 'application/wasm' },
status: 200,
});
}
if (url.includes('qrstream_raptorq_wasm_bg.wasm')) {
const bytes = await readFile(raptorqWasmPath);
const body = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
return new Response(body, {
headers: { 'Content-Type': 'application/wasm' },
status: 200,
});
}
if (!originalFetch) {
throw new Error(`Unexpected fetch in test: ${url}`);
}
+69 -1
View File
@@ -1,6 +1,17 @@
import { generateQRMatrix, getMaxByteCapacity, getMinVersion } from '../core/qr/qr_encode.ts';
import {
generateQRMatrix,
getMaxByteCapacity,
getMaxZXingWriterByteCapacity,
getMinVersion,
} from '../core/qr/qr_encode.ts';
import { rasterizeQR, rasterizeToGrayscale, getRasterDimensions } from '../core/qr/frame_raster.ts';
import { decodeQRFromBuffer, decodeQRCodesFromCanvas } from '../core/qr/qr_decode.ts';
import {
QR_ENCODERS,
encodeQRCodeMatrix,
renderQRCodeImageData,
} from '../core/qr/qr_encoder_browser.ts';
import { renderQRCodeImageDataWithZXing } from '../core/qr/qr_write_wasm.ts';
import { createQRGif, estimateGifSize } from '../core/gif/gif_render.ts';
import { describe, it, expect } from 'vitest';
@@ -11,6 +22,17 @@ describe('QR encode', () => {
expect(getMaxByteCapacity(40, 'M')).toBeGreaterThan(2000);
});
it('should account for ZXing writer binary ECI overhead in transfer profiles', async () => {
const { createQRTransferProfile, getQRTransferProfile } = await import('@/core/protocol/profiles');
expect(getMaxByteCapacity(20, 'L')).toBe(858);
expect(getMaxZXingWriterByteCapacity(20, 'L')).toBe(856);
expect(getQRTransferProfile('v20-l').maxPacketSize).toBe(856);
expect(getQRTransferProfile('v20-l').maxPayloadSize).toBe(844);
expect(createQRTransferProfile(20, 'L', 'js-qrcode').maxPacketSize).toBe(858);
expect(createQRTransferProfile(20, 'L', 'js-qrcode').maxPayloadSize).toBe(846);
});
it('should expose low-ECC transfer profiles with more payload room', async () => {
const { getQRTransferProfile } = await import('@/core/protocol/profiles');
@@ -31,6 +53,21 @@ describe('QR encode', () => {
expect(matrix.length).toBe(21);
expect(matrix[0]!.length).toBe(21);
});
it('should expose an encoder abstraction for JS QR and ZXing WASM', async () => {
const payload = new TextEncoder().encode('encoder facade');
for (const encoder of QR_ENCODERS) {
const matrix = await encodeQRCodeMatrix(payload, 10, 'L', encoder);
const image = await renderQRCodeImageData(payload, 10, 'L', 3, encoder);
const decoded = await decodeQRCodesFromCanvas(image, 1);
expect(matrix.length).toBe(57);
expect(matrix[0]!.length).toBe(57);
expect(decoded).toHaveLength(1);
expect(new TextDecoder().decode(decoded[0]!.bytes)).toBe('encoder facade');
}
});
});
describe('Frame raster', () => {
@@ -89,6 +126,37 @@ describe('Frame raster', () => {
expect(texts).toEqual([...payloads].sort());
});
it('should round-trip QR images written by ZXing WASM', async () => {
const payload = new Uint8Array([0, 1, 2, 3, 4, 5, 127, 128, 255]);
const image = await renderQRCodeImageDataWithZXing(payload, 10, 'L', 4);
const decoded = await decodeQRCodesFromCanvas(image, 1);
expect(image.width).toBe((10 * 4 + 17 + 8) * 4);
expect(decoded).toHaveLength(1);
expect(decoded[0]!.version).toBe(10);
expect(decoded[0]!.bytes).toEqual(payload);
});
it('should write and read a full V20-L transfer packet with ZXing WASM', async () => {
const { getQRTransferProfile } = await import('@/core/protocol/profiles');
const profile = getQRTransferProfile('v20-l');
const packet = new Uint8Array(profile.maxPacketSize);
for (let i = 0; i < packet.length; i++) packet[i] = i & 0xff;
const image = await renderQRCodeImageDataWithZXing(
packet,
profile.version,
profile.eccLevel,
2,
);
const decoded = await decodeQRCodesFromCanvas(image, 1);
expect(decoded).toHaveLength(1);
expect(decoded[0]!.version).toBe(20);
expect(decoded[0]!.bytes).toEqual(packet);
});
});
describe('GIF render', () => {
+25 -13
View File
@@ -1,13 +1,16 @@
/**
* GIF generation worker receives packets, generates QR matrices,
* rasterizes them, and creates an animated GIF.
* GIF generation worker receives packets, renders QR frames with the
* selected QR encoder, and creates an animated GIF.
*
* @module
*/
import { generateQRMatrix } from '@/core/qr/qr_encode';
import type { EccLevel } from '@/core/qr/qr_encode';
import { rasterizeQR } from '@/core/qr/frame_raster';
import {
normalizeQREncoder,
renderQRCodeImageData,
type QREncoder,
} from '@/core/qr/qr_encoder_browser';
import { createQRGif } from '@/core/gif/gif_render';
import { QR_VERSION, ECC_LEVEL, FRAME_DELAY_MS } from '@/core/protocol/constants';
import {
@@ -24,6 +27,7 @@ interface GenerateInput {
frameDelayMs?: number;
qrVersion?: number;
eccLevel?: EccLevel;
qrEncoder?: QREncoder;
parallelCount?: number;
}
@@ -46,19 +50,22 @@ self.onmessage = (e: MessageEvent<GenerateInput>) => {
const msg = e.data;
if (msg.type !== 'generate') return;
try {
const result = handleGenerate(msg);
self.postMessage(result, [result.gifData]);
} catch (err: any) {
self.postMessage({ type: 'error', message: err.message ?? String(err) } satisfies ErrorOutput);
}
void (async () => {
try {
const result = await handleGenerate(msg);
self.postMessage(result, [result.gifData]);
} catch (err: any) {
self.postMessage({ type: 'error', message: err.message ?? String(err) } satisfies ErrorOutput);
}
})();
};
function handleGenerate(input: GenerateInput): GifOutput {
async function handleGenerate(input: GenerateInput): Promise<GifOutput> {
const { packets } = input;
const frameDelayMs = normalizeFrameDelayMs(input.frameDelayMs);
const qrVersion = normalizeQRVersion(input.qrVersion);
const eccLevel = normalizeEccLevel(input.eccLevel);
const qrEncoder = normalizeQREncoder(input.qrEncoder);
const parallelCount = normalizeParallelQRCount(input.parallelCount);
const moduleCount = qrVersion * 4 + 17;
@@ -84,8 +91,13 @@ function handleGenerate(input: GenerateInput): GifOutput {
for (let tileIndex = 0; tileIndex < parallelCount; tileIndex++) {
const packetIndex = stripedPacketIndex(packets.length, parallelCount, frameIndex, tileIndex);
if (packetIndex === null) continue;
const matrix = generateQRMatrix(packets[packetIndex]!, qrVersion, eccLevel);
const imageData = rasterizeQR(matrix, scale);
const imageData = await renderQRCodeImageData(
packets[packetIndex]!,
qrVersion,
eccLevel,
scale,
qrEncoder,
);
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);
+87
View File
@@ -0,0 +1,87 @@
/**
* Live sender QR renderer.
*
* Renders packet QR tiles off the UI thread using the selected QR encoder.
* The main thread keeps a bounded cache and only composites ready tiles onto
* the visible canvas.
*/
import {
normalizeQREncoder,
renderQRCodeImageData,
type QREncoder,
} from '@/core/qr/qr_encoder_browser';
import type { EccLevel } from '@/core/qr/qr_encode';
interface RenderTileTask {
packetIndex: number;
packet: Uint8Array;
}
interface RenderTilesInput {
type: 'renderTiles';
requestId: number;
qrVersion: number;
eccLevel: EccLevel;
qrEncoder?: QREncoder;
tiles: RenderTileTask[];
}
interface TileOutput {
type: 'tile';
requestId: number;
packetIndex: number;
imageData: ImageData;
}
interface DoneOutput {
type: 'done';
requestId: number;
}
interface ErrorOutput {
type: 'error';
requestId: number;
packetIndex?: number;
message: string;
}
self.onmessage = (e: MessageEvent<RenderTilesInput>) => {
const msg = e.data;
if (msg.type !== 'renderTiles') return;
void renderTiles(msg);
};
async function renderTiles(input: RenderTilesInput): Promise<void> {
const qrEncoder = normalizeQREncoder(input.qrEncoder);
for (const tile of input.tiles) {
try {
const imageData = await renderQRCodeImageData(
tile.packet,
input.qrVersion,
input.eccLevel,
1,
qrEncoder,
);
self.postMessage(
{
type: 'tile',
requestId: input.requestId,
packetIndex: tile.packetIndex,
imageData,
} satisfies TileOutput,
{ transfer: [imageData.data.buffer as ArrayBuffer] },
);
} catch (err: any) {
self.postMessage({
type: 'error',
requestId: input.requestId,
packetIndex: tile.packetIndex,
message: err.message ?? String(err),
} satisfies ErrorOutput);
}
}
self.postMessage({ type: 'done', requestId: input.requestId } satisfies DoneOutput);
}