mirror of
https://github.com/infrost/RaptorQR.git
synced 2026-08-30 14:39:58 +08:00
add zxing-wasm qr encode method
This commit is contained in:
+286
-45
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user