From af28cd45dfd89e7dcc485d13e45697609a55572e Mon Sep 17 00:00:00 2001 From: infrost Date: Tue, 7 Jul 2026 21:24:22 +0100 Subject: [PATCH] add zxing-wasm qr encode method --- src/app/routes/sender.tsx | 331 ++++++++++++++++++++++++++---- src/cli/qr-stream.ts | 16 +- src/core/protocol/profiles.ts | 19 +- src/core/qr/qr_encode.ts | 59 ++++-- src/core/qr/qr_encoder.ts | 73 +++++++ src/core/qr/qr_encoder_browser.ts | 49 +++++ src/core/qr/qr_write_wasm.ts | 222 ++++++++++++++++++++ src/tests/cli_rendering.test.ts | 8 +- src/tests/raptorq_codec.test.ts | 2 +- src/tests/setup.ts | 33 +++ src/tests/test_qr_modules.test.ts | 70 ++++++- src/workers/gif.worker.ts | 38 ++-- src/workers/qr_render.worker.ts | 87 ++++++++ 13 files changed, 920 insertions(+), 87 deletions(-) create mode 100644 src/core/qr/qr_encoder.ts create mode 100644 src/core/qr/qr_encoder_browser.ts create mode 100644 src/core/qr/qr_write_wasm.ts create mode 100644 src/workers/qr_render.worker.ts diff --git a/src/app/routes/sender.tsx b/src/app/routes/sender.tsx index d634abf..0cf040d 100644 --- a/src/app/routes/sender.tsx +++ b/src/app/routes/sender.tsx @@ -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; + frames: Map; 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; @@ -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(DEFAULT_QR_VERSION); const [eccLevel, setEccLevel] = useState(DEFAULT_QR_ECC_LEVEL); + const [qrEncoder, setQrEncoder] = useState(DEFAULT_QR_ENCODER); const [fecCodec, setFecCodec] = useState(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(null); const encodeWorkerRef = useRef(null); const gifWorkerRef = useRef(null); + const qrRenderWorkerRef = useRef(null); const playbackTimerRef = useRef(null); const liveFrameIndexRef = useRef(0); const frameRateFpsRef = useRef(DEFAULT_FRAME_RATE_FPS); const frameCacheRef = useRef(createFrameCache()); + const requestedPacketImagesRef = useRef>(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) => { + 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 (
+ {/* ── Input mode toggle ───────────────────────────────────────────────── */}
@@ -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 (
+
+
+ QR encoder + {formatQREncoder(qrEncoder)} +
+ +
FEC codec @@ -811,6 +1009,7 @@ export function SenderPage() {
Live QR Transfer
{formatBytes(stats.preprocessedSize)} QR size {liveTransfer ? `V${liveTransfer.version}-${liveTransfer.eccLevel}` : qrProfile.label} + QR encoder + {formatQREncoder(stats.qrEncoder)} Symbol payload {liveTransfer ? `${liveTransfer.symbolSize} B/frame` : `${qrProfile.maxPayloadSize} B/frame`} FEC codec @@ -943,9 +1144,51 @@ function createFrameCache(): FrameCache { return { frames: new Map(), maxEntries: FRAME_CACHE_LIMIT }; } +async function prepareRenderedTile(imageData: ImageData): Promise { + 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 } { diff --git a/src/cli/qr-stream.ts b/src/cli/qr-stream.ts index 57edc26..2cf3ad9 100644 --- a/src/cli/qr-stream.ts +++ b/src/cli/qr-stream.ts @@ -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); +}); diff --git a/src/core/protocol/profiles.ts b/src/core/protocol/profiles.ts index c1cee69..4524d71 100644 --- a/src/core/protocol/profiles.ts +++ b/src/core/protocol/profiles.ts @@ -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, }; diff --git a/src/core/qr/qr_encode.ts b/src/core/qr/qr_encode.ts index e2948cd..322b5cc 100644 --- a/src/core/qr/qr_encode.ts +++ b/src/core/qr/qr_encode.ts @@ -109,6 +109,7 @@ const RS_BLOCK_TABLE: number[][] = [ // --------------------------------------------------------------------------- const ECC_INDEX: Record = { 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 = { 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 // --------------------------------------------------------------------------- diff --git a/src/core/qr/qr_encoder.ts b/src/core/qr/qr_encoder.ts new file mode 100644 index 0000000..ceb3ed0 --- /dev/null +++ b/src/core/qr/qr_encoder.ts @@ -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 { + 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 { + if (encoder !== 'js-qrcode') { + throw new Error('ZXing WASM QR encoder is only available through qr_encoder_browser.'); + } + return renderQRCodeImageDataWithJS(data, version, eccLevel, scale); +} diff --git a/src/core/qr/qr_encoder_browser.ts b/src/core/qr/qr_encoder_browser.ts new file mode 100644 index 0000000..643004e --- /dev/null +++ b/src/core/qr/qr_encoder_browser.ts @@ -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 { + 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 { + switch (encoder) { + case 'js-qrcode': + return renderQRCodeImageDataWithJS(data, version, eccLevel, scale); + case 'zxing-wasm': + return renderQRCodeImageDataWithZXing(data, version, eccLevel, scale); + } +} diff --git a/src/core/qr/qr_write_wasm.ts b/src/core/qr/qr_write_wasm.ts new file mode 100644 index 0000000..bf558e8 --- /dev/null +++ b/src/core/qr/qr_write_wasm.ts @@ -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 | null = null; + +export async function renderQRCodeImageDataWithZXing( + data: Uint8Array, + version: number, + eccLevel: EccLevel, + scale: number, +): Promise { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/tests/cli_rendering.test.ts b/src/tests/cli_rendering.test.ts index 9249eda..876c92c 100644 --- a/src/tests/cli_rendering.test.ts +++ b/src/tests/cli_rendering.test.ts @@ -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)); diff --git a/src/tests/raptorq_codec.test.ts b/src/tests/raptorq_codec.test.ts index a6e64f9..22d5e22 100644 --- a/src/tests/raptorq_codec.test.ts +++ b/src/tests/raptorq_codec.test.ts @@ -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'); diff --git a/src/tests/setup.ts b/src/tests/setup.ts index e4bc26d..7bce887 100644 --- a/src/tests/setup.ts +++ b/src/tests/setup.ts @@ -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}`); } diff --git a/src/tests/test_qr_modules.test.ts b/src/tests/test_qr_modules.test.ts index 441956a..a0db7f3 100644 --- a/src/tests/test_qr_modules.test.ts +++ b/src/tests/test_qr_modules.test.ts @@ -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', () => { diff --git a/src/workers/gif.worker.ts b/src/workers/gif.worker.ts index fe12bb1..52882a3 100644 --- a/src/workers/gif.worker.ts +++ b/src/workers/gif.worker.ts @@ -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) => { 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 { 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); diff --git a/src/workers/qr_render.worker.ts b/src/workers/qr_render.worker.ts new file mode 100644 index 0000000..0c5e8df --- /dev/null +++ b/src/workers/qr_render.worker.ts @@ -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) => { + const msg = e.data; + if (msg.type !== 'renderTiles') return; + + void renderTiles(msg); +}; + +async function renderTiles(input: RenderTilesInput): Promise { + 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); +}