Parallel QR 默认改成 4,选项扩到 1/2/4/6/8,live 和 GIF 布局都支持 6 = 3x2、8 = 4x2。

Sender 里 QR encoder、FEC codec、RaptorQ repair 都收进了 Advanced settings。
RaptorQ repair 默认从 20% 改成 10%。
Receiver 的 Max symbols 现在显示为 Auto (4),并且手动支持 6/8。
Sender 选 6/8 时会提示:Receiver 的 Auto (4) 只扫 4 个,需要手动把 Max symbols 设成对应的 6/8。
修了 fast_qr 容量:只有 ZXing WASM 继续扣那 2 bytes,fast_qr WASM 和 JS QR 都使用完整 QR byte capacity。
顺手修了 normalizeFecCodec('js-rlnc') 会被归回 wasm 默认的问题,不然高级设置里的 JS compatible 会选不住。
This commit is contained in:
infrost
2026-07-08 02:37:13 +01:00
parent f30757a276
commit 001f152eec
9 changed files with 150 additions and 78 deletions
+4 -2
View File
@@ -1133,11 +1133,13 @@ function formatDecodePreset(value: DecodePresetId): string {
function parseMaxSymbolsMode(value: string): MaxSymbolsMode {
if (value === 'auto') return 'auto';
const parsed = Number(value);
return parsed === 1 || parsed === 2 || parsed === 4 ? parsed : DEFAULT_DECODE_SETTINGS.maxSymbols;
return parsed === 1 || parsed === 2 || parsed === 4 || parsed === 6 || parsed === 8
? parsed
: DEFAULT_DECODE_SETTINGS.maxSymbols;
}
function formatMaxSymbols(value: MaxSymbolsMode): string {
return value === 'auto' ? 'Auto' : String(value);
return value === 'auto' ? 'Auto (4)' : String(value);
}
function normalizeBinarizer(value: string): QrBinarizer {
+80 -58
View File
@@ -85,8 +85,8 @@ type CSSProps = Record<string, string | number>;
const MIN_FRAME_RATE_FPS = 2;
const MAX_FRAME_RATE_FPS = 60;
const DEFAULT_FRAME_RATE_FPS = 30;
const DEFAULT_PARALLEL_QR_COUNT: ParallelQRCount = 1;
const PARALLEL_QR_COUNTS: ParallelQRCount[] = [1, 2, 4];
const DEFAULT_PARALLEL_QR_COUNT: ParallelQRCount = 4;
const PARALLEL_QR_COUNTS: ParallelQRCount[] = [1, 2, 4, 6, 8];
const FEC_CODEC_OPTIONS: FecCodec[] = ['wasm-raptorq', 'js-rlnc'];
const LIVE_TARGET_PX = 360;
const QR_QUIET_ZONE_MODULES = 4;
@@ -258,6 +258,7 @@ export function SenderPage() {
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 [advancedGenerateOpen, setAdvancedGenerateOpen] = useState(false);
const [frameRateFps, setFrameRateFps] = useState(DEFAULT_FRAME_RATE_FPS);
const [actualLiveFps, setActualLiveFps] = useState(0);
const [renderReadyPackets, setRenderReadyPackets] = useState(0);
@@ -1023,63 +1024,76 @@ 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)}
<button
type="button"
style={S.btnSecondary}
onClick={() => setAdvancedGenerateOpen((open) => !open)}
>
{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>
<span style={S.infoValue}>{formatFecCodec(fecCodec)}</span>
</div>
<select
value={fecCodec}
style={S.select}
disabled={encodingLive}
onChange={(e) => handleFecCodecChange((e.target as HTMLSelectElement).value)}
>
{FEC_CODEC_OPTIONS.map((codec) => (
<option key={codec} value={codec}>
{formatFecCodec(codec)}
</option>
))}
</select>
</div>
{fecCodec === 'wasm-raptorq' && (
<div style={{ marginBottom: 16 }}>
<div style={{ ...S.row, justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={S.label}>RaptorQ repair</span>
<span style={S.infoValue}>{raptorqRepairPercent}%</span>
{advancedGenerateOpen ? 'Hide advanced settings' : 'Advanced settings'}
</button>
{advancedGenerateOpen && (
<div style={{ marginTop: 14, display: 'grid', gap: 16 }}>
<label>
<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>
</label>
<label>
<div style={{ ...S.row, justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={S.label}>FEC codec</span>
<span style={S.infoValue}>{formatFecCodec(fecCodec)}</span>
</div>
<select
value={fecCodec}
style={S.select}
disabled={encodingLive}
onChange={(e) => handleFecCodecChange((e.target as HTMLSelectElement).value)}
>
{FEC_CODEC_OPTIONS.map((codec) => (
<option key={codec} value={codec}>
{formatFecCodec(codec)}
</option>
))}
</select>
</label>
{fecCodec === 'wasm-raptorq' && (
<label>
<div style={{ ...S.row, justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={S.label}>RaptorQ repair</span>
<span style={S.infoValue}>{raptorqRepairPercent}%</span>
</div>
<input
type="range"
min={MIN_RAPTORQ_REPAIR_PERCENT}
max={MAX_RAPTORQ_REPAIR_PERCENT}
step={1}
value={raptorqRepairPercent}
style={S.slider}
disabled={encodingLive}
onInput={(e) => handleRaptorQRepairPercentChange((e.target as HTMLInputElement).value)}
/>
<div style={S.sliderLabels}>
<span>Less QR</span>
<span>More repair</span>
</div>
</label>
)}
</div>
<input
type="range"
min={MIN_RAPTORQ_REPAIR_PERCENT}
max={MAX_RAPTORQ_REPAIR_PERCENT}
step={1}
value={raptorqRepairPercent}
style={S.slider}
disabled={encodingLive}
onInput={(e) => handleRaptorQRepairPercentChange((e.target as HTMLInputElement).value)}
/>
<div style={S.sliderLabels}>
<span>Less QR</span>
<span>More repair</span>
</div>
</div>
)}
)}
</div>
<div style={{ marginBottom: 16 }}>
<div style={{ ...S.row, justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={S.label}>QR speed</span>
@@ -1116,6 +1130,12 @@ export function SenderPage() {
</option>
))}
</select>
{parallelQRCount > 4 && (
<div style={S.warn}>
Receiver Auto (4) scans up to 4 QR codes per frame. For {parallelQRCount} parallel QR,
set Receiver advanced settings - Max symbols to {parallelQRCount}.
</div>
)}
</div>
<div style={S.row}>
<button
@@ -1502,7 +1522,9 @@ function normalizeFrameIndex(frameIndex: number, frameCount: number): number {
function getParallelLayout(parallelCount: ParallelQRCount): { columns: number; rows: number } {
if (parallelCount === 1) return { columns: 1, rows: 1 };
if (parallelCount === 2) return { columns: 2, rows: 1 };
return { columns: 2, rows: 2 };
if (parallelCount === 4) return { columns: 2, rows: 2 };
if (parallelCount === 6) return { columns: 3, rows: 2 };
return { columns: 4, rows: 2 };
}
function getPacketIndexForDisplayFrame(
+5 -2
View File
@@ -3,12 +3,15 @@ export type ReceiverFecCodec = 'auto' | FecCodec;
export const DEFAULT_FEC_CODEC: FecCodec = 'wasm-raptorq';
export const DEFAULT_RECEIVER_FEC_CODEC: ReceiverFecCodec = 'auto';
export const DEFAULT_RAPTORQ_REPAIR_PERCENT = 20;
export const DEFAULT_RAPTORQ_REPAIR_PERCENT = 10;
export const MIN_RAPTORQ_REPAIR_PERCENT = 0;
export const MAX_RAPTORQ_REPAIR_PERCENT = 100;
export function normalizeFecCodec(value: unknown): FecCodec {
return value === 'wasm-raptorq' ? 'wasm-raptorq' : DEFAULT_FEC_CODEC;
if (value === 'js-rlnc' || value === 'wasm-raptorq') {
return value;
}
return DEFAULT_FEC_CODEC;
}
export function normalizeReceiverFecCodec(value: unknown): ReceiverFecCodec {
+5 -5
View File
@@ -12,7 +12,7 @@ import {
getMaxZXingWriterByteCapacity,
type EccLevel,
} from '@/core/qr/qr_encode';
import type { QREncoder } from '@/core/qr/qr_encoder';
import { DEFAULT_QR_ENCODER, type QREncoder } from '@/core/qr/qr_encoder';
export interface QRTransferProfile {
id: string;
@@ -49,13 +49,13 @@ export const QR_TRANSFER_PROFILES: QRTransferProfile[] = PROFILE_SPECS.map((spec
export function createQRTransferProfile(
version: number,
eccLevel: EccLevel,
qrEncoder: QREncoder = 'zxing-wasm',
qrEncoder: QREncoder = DEFAULT_QR_ENCODER,
): QRTransferProfile {
const normalizedVersion = normalizeQRVersion(version);
const normalizedEccLevel = normalizeEccLevel(eccLevel);
const maxPacketSize = qrEncoder === 'js-qrcode'
? getMaxByteCapacity(normalizedVersion, normalizedEccLevel)
: getMaxZXingWriterByteCapacity(normalizedVersion, normalizedEccLevel);
const maxPacketSize = qrEncoder === 'zxing-wasm'
? getMaxZXingWriterByteCapacity(normalizedVersion, normalizedEccLevel)
: getMaxByteCapacity(normalizedVersion, normalizedEccLevel);
const maxPayloadSize = maxPacketSize - HEADER_SIZE - CRC32C_SIZE;
return {
id: profileId(normalizedVersion, normalizedEccLevel),
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Binarizer } from 'zxing-wasm/reader';
export type DecodePresetId = 'fast' | 'balance' | 'robust' | 'custom';
export type MaxSymbolsMode = 'auto' | 1 | 2 | 4;
export type MaxSymbolsMode = 'auto' | 1 | 2 | 4 | 6 | 8;
export type DownscaleFactor = 2 | 3 | 4;
export type QrBinarizer = Binarizer;
@@ -48,7 +48,7 @@ export const BINARIZER_OPTIONS: QrBinarizer[] = [
'BoolCast',
];
export const MAX_SYMBOL_OPTIONS: MaxSymbolsMode[] = ['auto', 1, 2, 4];
export const MAX_SYMBOL_OPTIONS: MaxSymbolsMode[] = ['auto', 1, 2, 4, 6, 8];
export const DOWNSCALE_FACTOR_OPTIONS: DownscaleFactor[] = [2, 3, 4];
export const DEFAULT_DECODE_PRESET: DecodePresetId = 'balance';
+2 -1
View File
@@ -39,6 +39,7 @@ const READER_OPTIONS: ReaderOptions = {
};
const DEFAULT_MAX_QR_SYMBOLS = 4;
const MAX_QR_SYMBOLS = 8;
const SINGLE_QR_DECODE_OPTIONS: Required<QrDecodeOptions> = {
...DEFAULT_DECODE_SETTINGS,
...DECODE_PRESETS.robust,
@@ -170,7 +171,7 @@ function parseQRVersion(version: string, extra: string): number {
function clampMaxSymbols(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_MAX_QR_SYMBOLS;
return Math.min(DEFAULT_MAX_QR_SYMBOLS, Math.max(1, Math.round(value)));
return Math.min(MAX_QR_SYMBOLS, Math.max(1, Math.round(value)));
}
function normalizeDecodeOptions(options: number | QrDecodeOptions): Required<QrDecodeOptions> {
+1 -1
View File
@@ -1,4 +1,4 @@
export type ParallelQRCount = 1 | 2 | 4;
export type ParallelQRCount = 1 | 2 | 4 | 6 | 8;
export function stripedFrameCount(packetCount: number, parallelCount: ParallelQRCount): number {
if (!Number.isInteger(packetCount) || packetCount < 0) {
+47 -5
View File
@@ -29,15 +29,19 @@ describe('QR encode', () => {
expect(getMaxByteCapacity(40, 'M')).toBeGreaterThan(2000);
});
it('should account for ZXing writer binary ECI overhead in transfer profiles', async () => {
it('should only apply ZXing writer binary ECI overhead to ZXing 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(getQRTransferProfile('v20-l').maxPacketSize).toBe(858);
expect(getQRTransferProfile('v20-l').maxPayloadSize).toBe(846);
expect(createQRTransferProfile(20, 'L', 'js-qrcode').maxPacketSize).toBe(858);
expect(createQRTransferProfile(20, 'L', 'js-qrcode').maxPayloadSize).toBe(846);
expect(createQRTransferProfile(20, 'L', 'fast-qr-wasm').maxPacketSize).toBe(858);
expect(createQRTransferProfile(20, 'L', 'fast-qr-wasm').maxPayloadSize).toBe(846);
expect(createQRTransferProfile(20, 'L', 'zxing-wasm').maxPacketSize).toBe(856);
expect(createQRTransferProfile(20, 'L', 'zxing-wasm').maxPayloadSize).toBe(844);
});
it('should expose low-ECC transfer profiles with more payload room', async () => {
@@ -196,9 +200,9 @@ describe('Frame raster', () => {
});
it('should write and read a full V20-L transfer packet with ZXing WASM', async () => {
const { getQRTransferProfile } = await import('@/core/protocol/profiles');
const { createQRTransferProfile } = await import('@/core/protocol/profiles');
const profile = getQRTransferProfile('v20-l');
const profile = createQRTransferProfile(20, 'L', 'zxing-wasm');
const packet = new Uint8Array(profile.maxPacketSize);
for (let i = 0; i < packet.length; i++) packet[i] = i & 0xff;
@@ -258,4 +262,42 @@ describe('Parallel striping', () => {
expect(seen).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
expect(emptyTiles).toBe(2);
});
it('should support 8-way striping without duplicating packets', async () => {
const { stripedFrameCount, stripedPacketIndex } = await import('@/core/sender/parallel_striping');
const packetCount = 17;
const parallelCount = 8;
const frameCount = stripedFrameCount(packetCount, parallelCount);
const seen: number[] = [];
let emptyTiles = 0;
for (let frameIndex = 0; frameIndex < frameCount; frameIndex++) {
for (let tileIndex = 0; tileIndex < parallelCount; tileIndex++) {
const packetIndex = stripedPacketIndex(packetCount, parallelCount, frameIndex, tileIndex);
if (packetIndex === null) {
emptyTiles++;
} else {
seen.push(packetIndex);
}
}
}
expect(frameCount).toBe(3);
expect(seen).toEqual(Array.from({ length: packetCount }, (_, index) => index));
expect(emptyTiles).toBe(7);
});
});
describe('Transfer defaults', () => {
it('should default RaptorQ repair to 10 percent and expose manual 6/8 decode symbols', async () => {
const { DEFAULT_RAPTORQ_REPAIR_PERCENT, normalizeFecCodec } = await import('@/core/fec/codec');
const { MAX_SYMBOL_OPTIONS, normalizeDecodeSettings } = await import('@/core/qr/decode_settings');
expect(DEFAULT_RAPTORQ_REPAIR_PERCENT).toBe(10);
expect(normalizeFecCodec('js-rlnc')).toBe('js-rlnc');
expect(normalizeFecCodec('wasm-raptorq')).toBe('wasm-raptorq');
expect(MAX_SYMBOL_OPTIONS).toEqual(['auto', 1, 2, 4, 6, 8]);
expect(normalizeDecodeSettings({ maxSymbols: 8 }).maxSymbols).toBe(8);
});
});
+4 -2
View File
@@ -136,13 +136,15 @@ function normalizeEccLevel(value: EccLevel | undefined): EccLevel {
}
function normalizeParallelQRCount(value: number | undefined): ParallelQRCount {
return value === 2 || value === 4 ? value : 1;
return value === 1 || value === 2 || value === 4 || value === 6 || value === 8 ? value : 4;
}
function getParallelLayout(parallelCount: ParallelQRCount): { columns: number; rows: number } {
if (parallelCount === 1) return { columns: 1, rows: 1 };
if (parallelCount === 2) return { columns: 2, rows: 1 };
return { columns: 2, rows: 2 };
if (parallelCount === 4) return { columns: 2, rows: 2 };
if (parallelCount === 6) return { columns: 3, rows: 2 };
return { columns: 4, rows: 2 };
}
function blitImageData(