quadqr-js 1.3.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,6 +24,8 @@ import {
24
24
  alignmentPatternCentersForVersion,
25
25
  alignmentPatternIsBlack,
26
26
  alignmentPatternRadius,
27
+ ALIGNMENT_PROFILE_STANDARD_5,
28
+ ALIGNMENT_PROFILE_LEGACY_3,
27
29
  sizeForVersion,
28
30
  versionFromSize
29
31
  } from "./geometry.js";
@@ -53,10 +55,26 @@ import {
53
55
  DEFAULT_PBKDF2_ITERATIONS,
54
56
  generateRaw256Key,
55
57
  normalizeRaw256Key,
56
- bytesToHex
58
+ bytesToHex,
59
+ estimateSecureEnvelopeOverhead
57
60
  } from "./security.js";
58
-
59
- export const FORMAT_VERSION = 5;
61
+ import {
62
+ compressDeflatePayload as compressDeflateBytes,
63
+ decompressDeflatePayload as decompressDeflateBytes,
64
+ DEFAULT_DEFLATE_LEVEL,
65
+ DEFLATE_LEVEL_MIN,
66
+ DEFLATE_LEVEL_MAX
67
+ } from "./deflate.js";
68
+ import {
69
+ compressBrotliPayload as compressBrotliBytes,
70
+ decompressBrotliPayload as decompressBrotliBytes,
71
+ DEFAULT_BROTLI_QUALITY,
72
+ BROTLI_QUALITY_MIN,
73
+ BROTLI_QUALITY_MAX
74
+ } from "./brotli.js";
75
+
76
+ export const FORMAT_VERSION = 6;
77
+ export const LEGACY_FORMAT_VERSION = 5;
60
78
  export const MIN_VERSION = 1;
61
79
  export const MAX_VERSION = 40;
62
80
  export const DEFAULT_ECC_LEVEL = "M";
@@ -85,7 +103,30 @@ export const PRINT_PALETTE = Object.freeze({
85
103
  export const COMPRESSION_MODES = Object.freeze({
86
104
  NONE: "none",
87
105
  AUTO: "auto",
88
- LZ: "lz"
106
+ SMART: "smart",
107
+ LZ: "lz",
108
+ DEFLATE: "deflate",
109
+ BROTLI: "brotli"
110
+ });
111
+
112
+ export const LZ_LEVEL_MIN = 1;
113
+ export const LZ_LEVEL_MAX = 9;
114
+ export const DEFAULT_LZ_LEVEL = 6;
115
+
116
+ export const COMPRESSION_LEVELS = Object.freeze({
117
+ lz: Object.freeze({ min: LZ_LEVEL_MIN, max: LZ_LEVEL_MAX, default: DEFAULT_LZ_LEVEL }),
118
+ deflate: Object.freeze({ min: DEFLATE_LEVEL_MIN, max: DEFLATE_LEVEL_MAX, default: DEFAULT_DEFLATE_LEVEL }),
119
+ brotli: Object.freeze({ min: BROTLI_QUALITY_MIN, max: BROTLI_QUALITY_MAX, default: DEFAULT_BROTLI_QUALITY }),
120
+ auto: Object.freeze({ lz: DEFAULT_LZ_LEVEL, deflate: DEFAULT_DEFLATE_LEVEL, brotli: 6 }),
121
+ smart: Object.freeze({
122
+ initialLz: DEFAULT_LZ_LEVEL,
123
+ initialDeflate: DEFAULT_DEFLATE_LEVEL,
124
+ initialBrotli: 6,
125
+ strongDeflate: 8,
126
+ strongBrotli: 9,
127
+ maximumDeflate: 9,
128
+ maximumBrotli: 11
129
+ })
89
130
  });
90
131
  export const SIGNATURE_ALGORITHMS = Object.freeze({ ED25519: "Ed25519" });
91
132
 
@@ -162,14 +203,14 @@ const TRIANGLE16_FLAG = 1 << 6;
162
203
  // Internal payload extension envelope. This is deliberately not a public
163
204
  // payload mode or content-type system. It exists only when compression or
164
205
  // signing needs metadata around an otherwise normal text/binary payload.
165
- const PAYLOAD_ENVELOPE_VERSION = 2;
206
+ const PAYLOAD_ENVELOPE_VERSION = 3;
166
207
  const PAYLOAD_ENVELOPE_MAGIC = new Uint8Array([0x51, 0x50, 0x58, 0x31]); // QPX1
167
208
  const PAYLOAD_ENVELOPE_HEADER_BYTES = 16;
168
209
  const PAYLOAD_ENVELOPE_SIGNED_FLAG = 1;
169
210
  const PAYLOAD_ENVELOPE_COMPRESSED_FLAG = 1 << 1;
170
211
  const PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG = 1 << 2;
171
- const PAYLOAD_ENVELOPE_COMPRESSION_IDS = Object.freeze({ none: 0, lz: 1 });
172
- const PAYLOAD_ENVELOPE_COMPRESSION_BY_ID = Object.freeze({ 0: "none", 1: "lz" });
212
+ const PAYLOAD_ENVELOPE_COMPRESSION_IDS = Object.freeze({ none: 0, lz: 1, deflate: 2, brotli: 3 });
213
+ const PAYLOAD_ENVELOPE_COMPRESSION_BY_ID = Object.freeze({ 0: "none", 1: "lz", 2: "deflate", 3: "brotli" });
173
214
  const PAYLOAD_ENVELOPE_SIGNATURE_IDS = Object.freeze({ none: 0, Ed25519: 1 });
174
215
  const PAYLOAD_ENVELOPE_SIGNATURE_BY_ID = Object.freeze({ 0: null, 1: "Ed25519" });
175
216
  const ECC_SHIFT = 1;
@@ -301,10 +342,36 @@ function readU16be(bytes, offset = 0) {
301
342
 
302
343
  function normalizeCompressionMode(value = "none") {
303
344
  const key = String(value).toLowerCase();
304
- assert(["none", "auto", "lz"].includes(key), "compression must be none, auto, or lz.");
345
+ assert(["none", "auto", "smart", "lz", "deflate", "brotli"].includes(key), "compression must be none, auto, smart, lz, deflate, or brotli.");
305
346
  return key;
306
347
  }
307
348
 
349
+ function normalizeExplicitCompressionLevel(mode, options = {}) {
350
+ const generic = options.compressionLevel;
351
+ if (mode === "lz") {
352
+ const value = Number(generic ?? options.lzLevel ?? DEFAULT_LZ_LEVEL);
353
+ assert(Number.isInteger(value) && value >= LZ_LEVEL_MIN && value <= LZ_LEVEL_MAX, `LZ compressionLevel must be ${LZ_LEVEL_MIN}..${LZ_LEVEL_MAX}.`);
354
+ return value;
355
+ }
356
+ if (mode === "deflate") {
357
+ const value = Number(generic ?? options.deflateLevel ?? DEFAULT_DEFLATE_LEVEL);
358
+ assert(Number.isInteger(value) && value >= DEFLATE_LEVEL_MIN && value <= DEFLATE_LEVEL_MAX, `DEFLATE compressionLevel must be ${DEFLATE_LEVEL_MIN}..${DEFLATE_LEVEL_MAX}.`);
359
+ return value;
360
+ }
361
+ if (mode === "brotli") {
362
+ const value = Number(generic ?? options.brotliQuality ?? DEFAULT_BROTLI_QUALITY);
363
+ assert(Number.isInteger(value) && value >= BROTLI_QUALITY_MIN && value <= BROTLI_QUALITY_MAX, `Brotli compressionLevel must be ${BROTLI_QUALITY_MIN}..${BROTLI_QUALITY_MAX}.`);
364
+ return value;
365
+ }
366
+ if (generic != null || options.lzLevel != null || options.deflateLevel != null || options.brotliQuality != null) {
367
+ assert(
368
+ mode === "auto" || mode === "smart",
369
+ "compressionLevel is only meaningful with compression: lz, compression: deflate, or compression: brotli."
370
+ );
371
+ }
372
+ return null;
373
+ }
374
+
308
375
  function asBytes(input) {
309
376
  if (input instanceof Uint8Array) return input;
310
377
  if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
@@ -312,10 +379,27 @@ function asBytes(input) {
312
379
  return new Uint8Array(input);
313
380
  }
314
381
 
315
- /** Portable LZSS-style compressor used by QuadQR payload compression. */
316
- export function compressPayload(input) {
382
+ /** Portable LZSS-style compressor used by QuadQR payload compression.
383
+ * Levels 1..9 only change encoder search effort. The wire format is unchanged,
384
+ * so every level is decoded by the same legacy LZ decoder. Level 6 preserves
385
+ * the historical QuadQR search depth and is the default.
386
+ */
387
+ export function compressPayload(input, options = {}) {
317
388
  const bytes = asBytes(input);
318
- if (bytes.length < 4) return bytes.slice();
389
+ const requestedLevel = typeof options === "number"
390
+ ? options
391
+ : (options.level ?? options.compressionLevel ?? DEFAULT_LZ_LEVEL);
392
+ const level = Number(requestedLevel);
393
+ assert(Number.isInteger(level) && level >= LZ_LEVEL_MIN && level <= LZ_LEVEL_MAX, `LZ level must be ${LZ_LEVEL_MIN}..${LZ_LEVEL_MAX}.`);
394
+
395
+ // Level 6 deliberately matches the original compressor's 32-candidate
396
+ // history exactly. Higher levels walk deeper chains and add bounded lazy
397
+ // lookahead, while lower levels reduce CPU work for faster encoding.
398
+ const candidateDepth = [0, 4, 8, 12, 16, 24, 32, 48, 64, 96][level];
399
+ const lazyDepth = [0, 0, 0, 0, 0, 0, 0, 12, 24, 48][level];
400
+ const lazyGain = level >= 9 ? 0 : level >= 7 ? 1 : 2;
401
+ const historyLimit = Math.max(candidateDepth, lazyDepth, 4);
402
+
319
403
  const out = [];
320
404
  const recent = new Map();
321
405
  let pos = 0;
@@ -327,38 +411,50 @@ export function compressPayload(input) {
327
411
  let list = recent.get(key);
328
412
  if (!list) recent.set(key, list = []);
329
413
  list.push(i);
330
- while (list.length > 32) list.shift();
414
+ while (list.length > historyLimit) list.shift();
331
415
  while (list.length && i - list[0] > 4095) list.shift();
332
416
  };
333
417
 
418
+ const findBest = (position, maxCandidates) => {
419
+ let bestLength = 0;
420
+ let bestOffset = 0;
421
+ const key = keyAt(position);
422
+ const candidates = key >= 0 ? (recent.get(key) ?? []) : [];
423
+ for (let ci = candidates.length - 1, checked = 0; ci >= 0 && checked < maxCandidates; ci--, checked++) {
424
+ const candidate = candidates[ci];
425
+ const offset = position - candidate;
426
+ if (offset <= 0 || offset > 4095) continue;
427
+ let length = 0;
428
+ while (length < 18 && position + length < bytes.length && bytes[candidate + length] === bytes[position + length]) length++;
429
+ if (length >= 3 && length > bestLength) {
430
+ bestLength = length;
431
+ bestOffset = offset;
432
+ if (length === 18) break;
433
+ }
434
+ }
435
+ return { length: bestLength, offset: bestOffset };
436
+ };
437
+
334
438
  while (pos < bytes.length) {
335
439
  const flagIndex = out.length;
336
440
  out.push(0);
337
441
  let flags = 0;
338
442
  for (let token = 0; token < 8 && pos < bytes.length; token++) {
339
- let bestLength = 0;
340
- let bestOffset = 0;
341
- const key = keyAt(pos);
342
- const candidates = key >= 0 ? (recent.get(key) ?? []) : [];
343
- for (let ci = candidates.length - 1; ci >= 0; ci--) {
344
- const candidate = candidates[ci];
345
- const offset = pos - candidate;
346
- if (offset <= 0 || offset > 4095) continue;
347
- let length = 0;
348
- while (length < 18 && pos + length < bytes.length && bytes[candidate + length] === bytes[pos + length]) length++;
349
- if (length >= 3 && length > bestLength) {
350
- bestLength = length;
351
- bestOffset = offset;
352
- if (length === 18) break;
353
- }
443
+ let best = findBest(pos, candidateDepth);
444
+
445
+ // Strong levels may emit one literal when the next byte begins a
446
+ // meaningfully longer match. This changes encoder effort only.
447
+ if (best.length >= 3 && lazyDepth > 0 && pos + 1 < bytes.length) {
448
+ const next = findBest(pos + 1, lazyDepth);
449
+ if (next.length > best.length + lazyGain) best = { length: 0, offset: 0 };
354
450
  }
355
451
 
356
- if (bestLength >= 3) {
452
+ if (best.length >= 3) {
357
453
  flags |= (1 << token);
358
- const encoded = ((bestOffset & 0x0fff) << 4) | ((bestLength - 3) & 0x0f);
454
+ const encoded = ((best.offset & 0x0fff) << 4) | ((best.length - 3) & 0x0f);
359
455
  out.push((encoded >>> 8) & 0xff, encoded & 0xff);
360
- for (let i = 0; i < bestLength; i++) remember(pos + i);
361
- pos += bestLength;
456
+ for (let i = 0; i < best.length; i++) remember(pos + i);
457
+ pos += best.length;
362
458
  } else {
363
459
  out.push(bytes[pos]);
364
460
  remember(pos);
@@ -394,14 +490,210 @@ export function decompressPayload(input, expectedLength = null) {
394
490
  return Uint8Array.from(out);
395
491
  }
396
492
 
397
- function prepareCompressedBody(payload, compression = "none") {
493
+ /** Raw RFC 1951 fixed-Huffman DEFLATE helper used by Compression 3.0. */
494
+ export function compressDeflatePayload(input, options = {}) {
495
+ return compressDeflateBytes(asBytes(input), options);
496
+ }
497
+
498
+ /** Restore a raw DEFLATE payload produced by compressDeflatePayload(). */
499
+ export function decompressDeflatePayload(input, expectedLength = null) {
500
+ return decompressDeflateBytes(asBytes(input), expectedLength);
501
+ }
502
+
503
+ /** Portable Brotli helper used by Compression 3.0 in browsers and Node.js. */
504
+ export function compressBrotliPayload(input, options = {}) {
505
+ return compressBrotliBytes(asBytes(input), options);
506
+ }
507
+
508
+ /** Restore a Brotli payload produced by compressBrotliPayload(). */
509
+ export function decompressBrotliPayload(input, expectedLength = null) {
510
+ return decompressBrotliBytes(asBytes(input), expectedLength);
511
+ }
512
+
513
+ function makeCompressionCandidate(payload, compression, compressionLevel = null) {
514
+ if (compression === "none") return { body: payload, compression: "none", compressionLevel: null };
515
+ if (compression === "lz") {
516
+ return {
517
+ body: compressPayload(payload, { level: compressionLevel ?? DEFAULT_LZ_LEVEL }),
518
+ compression: "lz",
519
+ compressionLevel: compressionLevel ?? DEFAULT_LZ_LEVEL
520
+ };
521
+ }
522
+ if (compression === "deflate") {
523
+ return {
524
+ body: compressDeflatePayload(payload, { level: compressionLevel ?? DEFAULT_DEFLATE_LEVEL }),
525
+ compression: "deflate",
526
+ compressionLevel: compressionLevel ?? DEFAULT_DEFLATE_LEVEL
527
+ };
528
+ }
529
+ if (compression === "brotli") {
530
+ return {
531
+ body: compressBrotliPayload(payload, { quality: compressionLevel ?? DEFAULT_BROTLI_QUALITY }),
532
+ compression: "brotli",
533
+ compressionLevel: compressionLevel ?? DEFAULT_BROTLI_QUALITY
534
+ };
535
+ }
536
+ throw new Error(`Unsupported compression candidate ${compression}.`);
537
+ }
538
+
539
+ function candidateFinalPayloadLength(candidate, payload, options = {}) {
540
+ const outerFixedBytes = Math.max(0, Number(options.outerFixedBytes ?? 0) || 0);
541
+ if (options.envelopeAlreadyRequired) {
542
+ const envelopeFixedBytes = Math.max(
543
+ PAYLOAD_ENVELOPE_HEADER_BYTES,
544
+ Number(options.envelopeFixedBytes ?? PAYLOAD_ENVELOPE_HEADER_BYTES) || PAYLOAD_ENVELOPE_HEADER_BYTES
545
+ );
546
+ return outerFixedBytes + envelopeFixedBytes + candidate.body.length;
547
+ }
548
+ const innerBytes = candidate.compression === "none"
549
+ ? candidate.body.length
550
+ : PAYLOAD_ENVELOPE_HEADER_BYTES + candidate.body.length;
551
+ return outerFixedBytes + innerBytes;
552
+ }
553
+
554
+ function compareCompressionCandidates(a, b) {
555
+ if (a.finalLength !== b.finalLength) return a.finalLength - b.finalLength;
556
+ // Prefer the cheaper decoder when storage is exactly tied.
557
+ const rank = { none: 0, lz: 1, deflate: 2, brotli: 3 };
558
+ return (rank[a.compression] ?? 9) - (rank[b.compression] ?? 9);
559
+ }
560
+
561
+ function annotateCompressionCandidate(candidate, payload, options = {}) {
562
+ return { ...candidate, finalLength: candidateFinalPayloadLength(candidate, payload, options) };
563
+ }
564
+
565
+ function safeChooseCompressionVersion(payloadLength, versionOptions = {}) {
566
+ try {
567
+ return chooseVersion(payloadLength, versionOptions);
568
+ } catch {
569
+ return null;
570
+ }
571
+ }
572
+
573
+ function smartCompressionTarget(candidate, versionOptions = {}) {
574
+ const requested = versionOptions.version ?? "auto";
575
+ const minVersion = versionOptions.minVersion ?? MIN_VERSION;
576
+ const maxVersion = versionOptions.maxVersion ?? MAX_VERSION;
577
+
578
+ // A fixed version cannot become physically smaller, but Smart may still
579
+ // spend extra CPU when stronger compression is needed to make that exact
580
+ // requested version fit.
581
+ if (requested !== "auto") {
582
+ validateVersion(requested);
583
+ const info = getVersionInfo(requested, versionOptions);
584
+ if (candidate.finalLength <= info.capacityBytes) return null;
585
+ const gapBytes = candidate.finalLength - info.capacityBytes;
586
+ return {
587
+ currentVersion: null,
588
+ targetVersion: requested,
589
+ targetCapacityBytes: info.capacityBytes,
590
+ gapBytes,
591
+ gapRatio: candidate.finalLength > 0 ? gapBytes / candidate.finalLength : 0
592
+ };
593
+ }
594
+
595
+ const currentVersion = safeChooseCompressionVersion(candidate.finalLength, versionOptions);
596
+ const targetVersion = currentVersion == null ? maxVersion : currentVersion - 1;
597
+ if (targetVersion < minVersion) return null;
598
+ const info = getVersionInfo(targetVersion, versionOptions);
599
+ const gapBytes = Math.max(0, candidate.finalLength - info.capacityBytes);
600
+ return {
601
+ currentVersion,
602
+ targetVersion,
603
+ targetCapacityBytes: info.capacityBytes,
604
+ gapBytes,
605
+ gapRatio: candidate.finalLength > 0 ? gapBytes / candidate.finalLength : 0
606
+ };
607
+ }
608
+
609
+ function compressionCandidates(payload, options = {}) {
610
+ return [
611
+ makeCompressionCandidate(payload, "none"),
612
+ makeCompressionCandidate(payload, "lz", DEFAULT_LZ_LEVEL),
613
+ makeCompressionCandidate(payload, "deflate", DEFAULT_DEFLATE_LEVEL),
614
+ makeCompressionCandidate(payload, "brotli", 6)
615
+ ].map((candidate) => annotateCompressionCandidate(candidate, payload, options));
616
+ }
617
+
618
+ function chooseBestCompressionCandidate(candidates) {
619
+ return candidates.slice().sort(compareCompressionCandidates)[0];
620
+ }
621
+
622
+ function prepareSmartCompressedBody(payload, options = {}) {
623
+ const versionOptions = options.versionOptions ?? {};
624
+ const tried = [];
625
+ const seen = new Set();
626
+ const add = (compression, level = null) => {
627
+ const key = `${compression}:${level ?? ""}`;
628
+ if (seen.has(key)) return null;
629
+ seen.add(key);
630
+ const candidate = annotateCompressionCandidate(makeCompressionCandidate(payload, compression, level), payload, options);
631
+ tried.push(candidate);
632
+ return candidate;
633
+ };
634
+
635
+ add("none");
636
+ add("lz", DEFAULT_LZ_LEVEL);
637
+ add("deflate", DEFAULT_DEFLATE_LEVEL);
638
+ add("brotli", 6);
639
+ let best = chooseBestCompressionCandidate(tried);
640
+ const initialVersion = safeChooseCompressionVersion(best.finalLength, versionOptions);
641
+
642
+ // Smart is intentionally CPU-heavy, but it still avoids maximum-quality
643
+ // passes when the next smaller matrix is far outside realistic reach.
644
+ let target = smartCompressionTarget(best, versionOptions);
645
+ if (target && (target.gapRatio <= 0.30 || target.gapBytes <= 192)) {
646
+ add("deflate", 8);
647
+ add("brotli", 9);
648
+ best = chooseBestCompressionCandidate(tried);
649
+ target = smartCompressionTarget(best, versionOptions);
650
+
651
+ // Maximum passes are reserved for a genuinely close version boundary, or
652
+ // when the strong pass already crossed one boundary and another is close.
653
+ if (target && (target.gapRatio <= 0.16 || target.gapBytes <= 96)) {
654
+ add("deflate", 9);
655
+ add("brotli", 11);
656
+ best = chooseBestCompressionCandidate(tried);
657
+ }
658
+ }
659
+
660
+ const finalVersion = safeChooseCompressionVersion(best.finalLength, versionOptions);
661
+ return {
662
+ ...best,
663
+ compressionStrategy: "smart",
664
+ smartCompression: {
665
+ cpuHeavy: true,
666
+ initialVersion,
667
+ finalVersion,
668
+ levelsTried: tried
669
+ .filter((candidate) => candidate.compression === "deflate" || candidate.compression === "brotli")
670
+ .map((candidate) => ({ algorithm: candidate.compression, level: candidate.compressionLevel, bytes: candidate.body.length, finalBytes: candidate.finalLength }))
671
+ }
672
+ };
673
+ }
674
+
675
+ function prepareCompressedBody(payload, compression = "none", options = {}) {
398
676
  const mode = normalizeCompressionMode(compression);
399
- if (mode === "none") return { body: payload, compression: "none" };
400
- const compressed = compressPayload(payload);
401
- if (mode === "auto" && compressed.length >= payload.length - 2) {
402
- return { body: payload, compression: "none" };
677
+ if (mode === "none") return { ...makeCompressionCandidate(payload, "none"), compressionStrategy: "none" };
678
+ if (mode === "lz") {
679
+ const level = normalizeExplicitCompressionLevel(mode, options);
680
+ return { ...makeCompressionCandidate(payload, "lz", level), compressionStrategy: "explicit" };
681
+ }
682
+ if (mode === "deflate") {
683
+ const level = normalizeExplicitCompressionLevel(mode, options);
684
+ return { ...makeCompressionCandidate(payload, "deflate", level), compressionStrategy: "explicit" };
685
+ }
686
+ if (mode === "brotli") {
687
+ const level = normalizeExplicitCompressionLevel(mode, options);
688
+ return { ...makeCompressionCandidate(payload, "brotli", level), compressionStrategy: "explicit" };
403
689
  }
404
- return { body: compressed, compression: "lz" };
690
+ if (mode === "smart") return prepareSmartCompressedBody(payload, options);
691
+
692
+ // Auto deliberately performs one balanced pass per codec. It is the fast
693
+ // default; Smart is the opt-in mode that spends extra CPU near QR-version
694
+ // boundaries.
695
+ const best = chooseBestCompressionCandidate(compressionCandidates(payload, options));
696
+ return { ...best, compressionStrategy: "auto" };
405
697
  }
406
698
 
407
699
 
@@ -435,9 +727,7 @@ function isPayloadEnvelope(input) {
435
727
  return bytes.length >= PAYLOAD_ENVELOPE_HEADER_BYTES && PAYLOAD_ENVELOPE_MAGIC.every((value, index) => bytes[index] === value);
436
728
  }
437
729
 
438
- function packPayloadEnvelope(input, options = {}) {
439
- const payload = asBytes(input);
440
- const prepared = prepareCompressedBody(payload, options.compression ?? "none");
730
+ function packPreparedPayloadEnvelope(payload, prepared) {
441
731
  const flags = prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0;
442
732
  const header = makePayloadEnvelopeHeader({
443
733
  compression: prepared.compression,
@@ -447,11 +737,17 @@ function packPayloadEnvelope(input, options = {}) {
447
737
  return concatBytes(header, prepared.body);
448
738
  }
449
739
 
740
+ function packPayloadEnvelope(input, options = {}) {
741
+ const payload = asBytes(input);
742
+ const prepared = prepareCompressedBody(payload, options.compression ?? "none", options);
743
+ return packPreparedPayloadEnvelope(payload, prepared);
744
+ }
745
+
450
746
  function parsePayloadEnvelope(input) {
451
747
  const container = asBytes(input);
452
748
  assert(isPayloadEnvelope(container), "Payload extension envelope is not recognized.");
453
749
  const version = container[4];
454
- assert(version === 1 || version === PAYLOAD_ENVELOPE_VERSION, `Unsupported payload extension version ${version}.`);
750
+ assert(version >= 1 && version <= PAYLOAD_ENVELOPE_VERSION, `Unsupported payload extension version ${version}.`);
455
751
  const flags = container[5];
456
752
  const compression = PAYLOAD_ENVELOPE_COMPRESSION_BY_ID[container[6]];
457
753
  assert(compression != null, `Unknown payload compression id ${container[6]}.`);
@@ -469,7 +765,13 @@ function parsePayloadEnvelope(input) {
469
765
  const publicKey = container.slice(endMetadata, endPublicKey);
470
766
  const signature = container.slice(endPublicKey, endSignature);
471
767
  const stored = container.slice(endSignature);
472
- const payload = compression === "lz" ? decompressPayload(stored, originalLength) : stored;
768
+ const payload = compression === "lz"
769
+ ? decompressPayload(stored, originalLength)
770
+ : compression === "deflate"
771
+ ? decompressDeflatePayload(stored, originalLength)
772
+ : compression === "brotli"
773
+ ? decompressBrotliPayload(stored, originalLength)
774
+ : stored;
473
775
  assert(payload.length === originalLength, "Payload length mismatch after extension decoding.");
474
776
  const signed = Boolean(flags & PAYLOAD_ENVELOPE_SIGNED_FLAG);
475
777
  const signedBytes = signed ? concatBytes(container.slice(0, PAYLOAD_ENVELOPE_HEADER_BYTES), metadataBytes, publicKey, stored) : null;
@@ -558,7 +860,6 @@ async function packSignedPayloadEnvelope(input, options = {}) {
558
860
  const payload = asBytes(input);
559
861
  assert(options.privateKey, "privateKey is required to sign a QuadQR payload.");
560
862
  const subtle = subtleCrypto();
561
- const prepared = prepareCompressedBody(payload, options.compression ?? "auto");
562
863
  const keyIdBytes = normalizeSigningKeyId(options.keyId);
563
864
 
564
865
  let publicKeyBytes = new Uint8Array(0);
@@ -568,6 +869,14 @@ async function packSignedPayloadEnvelope(input, options = {}) {
568
869
  publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", publicKeyObject));
569
870
  }
570
871
 
872
+ const signedEnvelopeFixedBytes = PAYLOAD_ENVELOPE_HEADER_BYTES + keyIdBytes.length + publicKeyBytes.length + 64;
873
+ const prepared = prepareCompressedBody(payload, options.compression ?? "auto", {
874
+ ...options,
875
+ envelopeAlreadyRequired: true,
876
+ envelopeFixedBytes: signedEnvelopeFixedBytes,
877
+ versionOptions: options.versionOptions ?? options
878
+ });
879
+
571
880
  const flags = PAYLOAD_ENVELOPE_SIGNED_FLAG |
572
881
  (prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0) |
573
882
  (publicKeyBytes.length ? PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG : 0);
@@ -584,7 +893,13 @@ async function packSignedPayloadEnvelope(input, options = {}) {
584
893
  const privateKey = await normalizeEd25519PrivateKey(options.privateKey);
585
894
  const signature = new Uint8Array(await subtle.sign({ name: "Ed25519" }, privateKey, signedBytes));
586
895
  assert(signature.length === 64, "Unexpected Ed25519 signature length.");
587
- return concatBytes(header, keyIdBytes, publicKeyBytes, signature, prepared.body);
896
+ return {
897
+ envelope: concatBytes(header, keyIdBytes, publicKeyBytes, signature, prepared.body),
898
+ compression: prepared.compression,
899
+ compressionLevel: prepared.compressionLevel ?? null,
900
+ compressionStrategy: prepared.compressionStrategy ?? null,
901
+ smartCompression: prepared.smartCompression ?? null
902
+ };
588
903
  }
589
904
 
590
905
  function lookupTrustedSigningKey(trustedKeys, keyId) {
@@ -604,7 +919,7 @@ async function verifyPayloadEnvelopeSignature(input, options = {}) {
604
919
  let trustSource = trusted ? "external" : null;
605
920
 
606
921
  // Legacy v1 symbols embedded the public key by default, so preserve their
607
- // historical self-verification behavior. v2 requires explicit opt-in.
922
+ // historical self-verification behavior. v2+ require explicit opt-in.
608
923
  if (!trusted && parsed.publicKey.length && (parsed.version === 1 || options.allowEmbeddedKey === true)) {
609
924
  trusted = parsed.publicKey;
610
925
  trustSource = "embedded";
@@ -635,19 +950,31 @@ async function verifyPayloadEnvelopeSignature(input, options = {}) {
635
950
  };
636
951
  }
637
952
 
638
- function prepareOptionalCompressedPayload(input, compression = "none") {
953
+ function prepareOptionalCompressedPayload(input, compression = "none", options = {}) {
639
954
  const payload = asBytes(input);
640
955
  const mode = normalizeCompressionMode(compression);
641
- if (mode === "none") return { payload, extended: false, compression: "none" };
642
- const prepared = prepareCompressedBody(payload, mode);
643
- // Auto mode is truly zero-overhead when compression does not help.
644
- if (mode === "auto" && prepared.compression === "none") {
645
- return { payload, extended: false, compression: "none" };
956
+ if (mode === "none") {
957
+ return { payload, extended: false, compression: "none", compressionLevel: null, compressionStrategy: "none", smartCompression: null };
958
+ }
959
+ const prepared = prepareCompressedBody(payload, mode, { ...options, versionOptions: options.versionOptions ?? options });
960
+ // Auto and Smart are truly zero-overhead when compression does not help.
961
+ if ((mode === "auto" || mode === "smart") && prepared.compression === "none") {
962
+ return {
963
+ payload,
964
+ extended: false,
965
+ compression: "none",
966
+ compressionLevel: null,
967
+ compressionStrategy: prepared.compressionStrategy,
968
+ smartCompression: prepared.smartCompression ?? null
969
+ };
646
970
  }
647
971
  return {
648
- payload: packPayloadEnvelope(payload, { compression: prepared.compression }),
972
+ payload: packPreparedPayloadEnvelope(payload, prepared),
649
973
  extended: true,
650
- compression: prepared.compression
974
+ compression: prepared.compression,
975
+ compressionLevel: prepared.compressionLevel ?? null,
976
+ compressionStrategy: prepared.compressionStrategy ?? mode,
977
+ smartCompression: prepared.smartCompression ?? null
651
978
  };
652
979
  }
653
980
 
@@ -809,7 +1136,7 @@ function getEffectiveEcc(version, eccLevel) {
809
1136
  return version === COMPACT_VERSION ? COMPACT_ECC_LEVELS[normalized] : ECC_LEVELS[normalized];
810
1137
  }
811
1138
 
812
- function makeHeader(payloadLength, flags, version) {
1139
+ function makeHeader(payloadLength, flags, version, formatVersion = FORMAT_VERSION) {
813
1140
  assert(payloadLength >= 0 && payloadLength <= 0xffffffff, "Payload is too large.");
814
1141
 
815
1142
  if (version === COMPACT_VERSION) {
@@ -824,7 +1151,7 @@ function makeHeader(payloadLength, flags, version) {
824
1151
 
825
1152
  const header = new Uint8Array(HEADER_BYTES);
826
1153
  header.set(MAGIC, 0);
827
- header[4] = FORMAT_VERSION;
1154
+ header[4] = formatVersion;
828
1155
  header[5] = flags & 0xff;
829
1156
  header.set(u32be(payloadLength), 6);
830
1157
  return header;
@@ -846,14 +1173,22 @@ function parseHeader(header, version) {
846
1173
  return { flags: header[1], payloadLength: header[2] };
847
1174
  }
848
1175
 
849
- if (!magicMatches(header) || header[4] !== FORMAT_VERSION) {
1176
+ const formatVersion = header[4];
1177
+ if (!magicMatches(header) || (formatVersion !== FORMAT_VERSION && formatVersion !== LEGACY_FORMAT_VERSION)) {
850
1178
  throw new Error("QuadQR magic/version mismatch.");
851
1179
  }
852
- return { flags: header[5], payloadLength: readU32be(header, 6) };
1180
+ return { formatVersion, flags: header[5], payloadLength: readU32be(header, 6) };
1181
+ }
1182
+
1183
+ function alignmentProfileForFormat(formatVersion = FORMAT_VERSION) {
1184
+ return formatVersion === LEGACY_FORMAT_VERSION
1185
+ ? ALIGNMENT_PROFILE_LEGACY_3
1186
+ : ALIGNMENT_PROFILE_STANDARD_5;
853
1187
  }
854
1188
 
855
- function createLayout(version) {
1189
+ function createLayout(version, options = {}) {
856
1190
  validateVersion(version);
1191
+ const alignmentProfile = options.alignmentProfile ?? alignmentProfileForFormat(options.formatVersion ?? FORMAT_VERSION);
857
1192
  const size = sizeForVersion(version);
858
1193
  const matrix = make2D(size, CELL.WHITE);
859
1194
  const reserved = make2D(size, false);
@@ -981,7 +1316,7 @@ function createLayout(version) {
981
1316
  reserveAndSet(row, 6, row % 2 === 0 ? CELL.BLACK : CELL.WHITE);
982
1317
  }
983
1318
 
984
- const alignments = alignmentPatternCentersForVersion(version).map(drawAlignmentPattern);
1319
+ const alignments = alignmentPatternCentersForVersion(version, { profile: alignmentProfile }).map(drawAlignmentPattern);
985
1320
  const alignment = alignments[alignments.length - 1];
986
1321
  const calibrationStrip = drawCalibrationStrip();
987
1322
 
@@ -1005,6 +1340,7 @@ function createLayout(version) {
1005
1340
  return {
1006
1341
  version,
1007
1342
  size,
1343
+ alignmentProfile,
1008
1344
  matrix,
1009
1345
  reserved,
1010
1346
  dataPositions,
@@ -1367,6 +1703,9 @@ function finalizeMatrix(layout, rawCells, meta) {
1367
1703
  secure: Boolean(meta.secure),
1368
1704
  compressed: meta.compression != null && meta.compression !== "none",
1369
1705
  compression: meta.compression ?? "none",
1706
+ compressionLevel: meta.compressionLevel ?? null,
1707
+ compressionStrategy: meta.compressionStrategy ?? null,
1708
+ smartCompression: meta.smartCompression ?? null,
1370
1709
  signed: Boolean(meta.signed),
1371
1710
  signingKeyId: meta.signingKeyId ?? null,
1372
1711
  hasEmbeddedPublicKey: Boolean(meta.hasEmbeddedPublicKey),
@@ -1427,6 +1766,9 @@ function encodePreparedBytes(input, options = {}) {
1427
1766
  extended,
1428
1767
  signed,
1429
1768
  compression: options.compressionMetadata ?? "none",
1769
+ compressionLevel: options.compressionLevelMetadata ?? null,
1770
+ compressionStrategy: options.compressionStrategyMetadata ?? null,
1771
+ smartCompression: options.smartCompressionMetadata ?? null,
1430
1772
  signingKeyId: options.signingKeyId ?? null,
1431
1773
  hasEmbeddedPublicKey: Boolean(options.hasEmbeddedPublicKey),
1432
1774
  security: options.securityMetadata ?? null,
@@ -1442,25 +1784,31 @@ function encodePreparedBytes(input, options = {}) {
1442
1784
  export function encodeText(text, options = {}) {
1443
1785
  assert(typeof text === "string", "encodeText expects a string.");
1444
1786
  const source = getTextEncoder().encode(text);
1445
- const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
1787
+ const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none", options);
1446
1788
  return encodePreparedBytes(prepared.payload, {
1447
1789
  ...options,
1448
1790
  text: true,
1449
1791
  extended: prepared.extended,
1450
1792
  sourcePayloadBytes: source.length,
1451
- compressionMetadata: prepared.compression
1793
+ compressionMetadata: prepared.compression,
1794
+ compressionLevelMetadata: prepared.compressionLevel,
1795
+ compressionStrategyMetadata: prepared.compressionStrategy,
1796
+ smartCompressionMetadata: prepared.smartCompression
1452
1797
  });
1453
1798
  }
1454
1799
 
1455
1800
  export function encodeBytes(input, options = {}) {
1456
1801
  const source = asBytes(input);
1457
- const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
1802
+ const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none", options);
1458
1803
  return encodePreparedBytes(prepared.payload, {
1459
1804
  ...options,
1460
1805
  text: Boolean(options.text),
1461
1806
  extended: prepared.extended,
1462
1807
  sourcePayloadBytes: source.length,
1463
- compressionMetadata: prepared.compression
1808
+ compressionMetadata: prepared.compression,
1809
+ compressionLevelMetadata: prepared.compressionLevel,
1810
+ compressionStrategyMetadata: prepared.compressionStrategy,
1811
+ smartCompressionMetadata: prepared.smartCompression
1464
1812
  });
1465
1813
  }
1466
1814
 
@@ -1472,15 +1820,18 @@ export function encodeUint8Array(input, options = {}) {
1472
1820
  /** Encode and sign a normal payload using an internal Ed25519 envelope. */
1473
1821
  export async function encodeSignedBytes(input, options = {}) {
1474
1822
  const source = asBytes(input);
1475
- const envelope = await packSignedPayloadEnvelope(source, options);
1476
- const parsed = parsePayloadEnvelope(envelope);
1477
- return encodePreparedBytes(envelope, {
1823
+ const packed = await packSignedPayloadEnvelope(source, options);
1824
+ const parsed = parsePayloadEnvelope(packed.envelope);
1825
+ return encodePreparedBytes(packed.envelope, {
1478
1826
  ...options,
1479
1827
  text: Boolean(options.text),
1480
1828
  extended: true,
1481
1829
  signed: true,
1482
1830
  sourcePayloadBytes: source.length,
1483
1831
  compressionMetadata: parsed.compression,
1832
+ compressionLevelMetadata: packed.compressionLevel,
1833
+ compressionStrategyMetadata: packed.compressionStrategy,
1834
+ smartCompressionMetadata: packed.smartCompression,
1484
1835
  signingKeyId: parsed.keyId,
1485
1836
  hasEmbeddedPublicKey: parsed.hasEmbeddedPublicKey
1486
1837
  });
@@ -1505,30 +1856,47 @@ export async function encodeSecureText(text, options = {}) {
1505
1856
  export async function encodeSecureBytes(input, options = {}) {
1506
1857
  const sourcePayload = asBytes(input);
1507
1858
  const security = options.security ?? {};
1859
+ const secureOuterFixedBytes = estimateSecureEnvelopeOverhead(security);
1508
1860
  let protectedPayload = sourcePayload;
1509
1861
  let extended = false;
1510
1862
  let signed = false;
1511
1863
  let compression = "none";
1864
+ let compressionLevel = null;
1865
+ let compressionStrategy = null;
1866
+ let smartCompression = null;
1512
1867
  let signingKeyId = null;
1513
1868
  let hasEmbeddedPublicKey = false;
1514
1869
 
1515
1870
  if (options.signing) {
1516
- protectedPayload = await packSignedPayloadEnvelope(sourcePayload, {
1871
+ const packed = await packSignedPayloadEnvelope(sourcePayload, {
1517
1872
  ...options,
1518
1873
  ...options.signing,
1519
- compression: options.compression ?? "auto"
1874
+ compression: options.compression ?? "auto",
1875
+ outerFixedBytes: secureOuterFixedBytes,
1876
+ versionOptions: options
1520
1877
  });
1878
+ protectedPayload = packed.envelope;
1521
1879
  const parsed = parsePayloadEnvelope(protectedPayload);
1522
1880
  extended = true;
1523
1881
  signed = true;
1524
1882
  compression = parsed.compression;
1883
+ compressionLevel = packed.compressionLevel;
1884
+ compressionStrategy = packed.compressionStrategy;
1885
+ smartCompression = packed.smartCompression;
1525
1886
  signingKeyId = parsed.keyId;
1526
1887
  hasEmbeddedPublicKey = parsed.hasEmbeddedPublicKey;
1527
1888
  } else {
1528
- const prepared = prepareOptionalCompressedPayload(sourcePayload, options.compression ?? "none");
1889
+ const prepared = prepareOptionalCompressedPayload(sourcePayload, options.compression ?? "none", {
1890
+ ...options,
1891
+ outerFixedBytes: secureOuterFixedBytes,
1892
+ versionOptions: options
1893
+ });
1529
1894
  protectedPayload = prepared.payload;
1530
1895
  extended = prepared.extended;
1531
1896
  compression = prepared.compression;
1897
+ compressionLevel = prepared.compressionLevel;
1898
+ compressionStrategy = prepared.compressionStrategy;
1899
+ smartCompression = prepared.smartCompression;
1532
1900
  }
1533
1901
 
1534
1902
  const encrypted = await encryptSecurePayload(protectedPayload, security);
@@ -1540,6 +1908,9 @@ export async function encodeSecureBytes(input, options = {}) {
1540
1908
  signed,
1541
1909
  sourcePayloadBytes: sourcePayload.length,
1542
1910
  compressionMetadata: compression,
1911
+ compressionLevelMetadata: compressionLevel,
1912
+ compressionStrategyMetadata: compressionStrategy,
1913
+ smartCompressionMetadata: smartCompression,
1543
1914
  signingKeyId,
1544
1915
  hasEmbeddedPublicKey,
1545
1916
  securityMetadata: encrypted.metadata
@@ -1618,8 +1989,8 @@ function alignmentPatternMismatchRatio(matrix, pattern) {
1618
1989
  return mismatches / total;
1619
1990
  }
1620
1991
 
1621
- function alignmentGridMismatchRatio(matrix, version) {
1622
- const patterns = alignmentPatternCentersForVersion(version);
1992
+ function alignmentGridMismatchRatio(matrix, version, alignmentProfile = ALIGNMENT_PROFILE_STANDARD_5) {
1993
+ const patterns = alignmentPatternCentersForVersion(version, { profile: alignmentProfile });
1623
1994
  let weightedMismatch = 0;
1624
1995
  let totalWeight = 0;
1625
1996
  for (const pattern of patterns) {
@@ -1631,7 +2002,7 @@ function alignmentGridMismatchRatio(matrix, version) {
1631
2002
  return totalWeight ? weightedMismatch / totalWeight : 0;
1632
2003
  }
1633
2004
 
1634
- function validateStructure(matrix, tolerance = 0) {
2005
+ function validateStructure(matrix, tolerance = 0, alignmentProfile = ALIGNMENT_PROFILE_STANDARD_5) {
1635
2006
  const size = matrix.length;
1636
2007
  if (size < 21 || matrix.some((row) => row.length !== size)) return false;
1637
2008
  const version = versionFromSize(size);
@@ -1644,11 +2015,11 @@ function validateStructure(matrix, tolerance = 0) {
1644
2015
  if (finderRatios.some((ratio) => ratio > tolerance)) return false;
1645
2016
 
1646
2017
  const alignmentTolerance = Math.max(tolerance, 0.12);
1647
- const primary = alignmentPatternCentersForVersion(version).at(-1);
2018
+ const primary = alignmentPatternCentersForVersion(version, { profile: alignmentProfile }).at(-1);
1648
2019
  if (primary && alignmentPatternMismatchRatio(matrix, primary) > alignmentTolerance) {
1649
2020
  return false;
1650
2021
  }
1651
- if (alignmentGridMismatchRatio(matrix, version) > alignmentTolerance) return false;
2022
+ if (alignmentGridMismatchRatio(matrix, version, alignmentProfile) > alignmentTolerance) return false;
1652
2023
  return true;
1653
2024
  }
1654
2025
 
@@ -1836,13 +2207,13 @@ function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConf
1836
2207
  };
1837
2208
  }
1838
2209
 
1839
- function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = null, options = {}) {
2210
+ function decodeCanonicalWithProfile(matrix, rotation, tolerance = 0, confidenceMatrix = null, options = {}, alignmentProfile = ALIGNMENT_PROFILE_STANDARD_5) {
1840
2211
  const size = matrix.length;
1841
2212
  const version = versionFromSize(size);
1842
2213
  if (!version) throw new Error(`Unsupported matrix size ${size}.`);
1843
- if (!validateStructure(matrix, tolerance)) throw new Error("QuadQR finder/alignment structure does not match.");
2214
+ if (!validateStructure(matrix, tolerance, alignmentProfile)) throw new Error("QuadQR finder/alignment structure does not match.");
1844
2215
 
1845
- const layout = createLayout(version);
2216
+ const layout = createLayout(version, { alignmentProfile });
1846
2217
  const errors = [];
1847
2218
  const hintedEncoding = options.cellEncodingHint != null
1848
2219
  ? normalizeCellEncoding(options.cellEncodingHint)
@@ -1892,6 +2263,10 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
1892
2263
  );
1893
2264
  const header = headerDecoded.header;
1894
2265
  const flags = headerDecoded.flags;
2266
+ const decodedFormatVersion = headerDecoded.formatVersion ?? FORMAT_VERSION;
2267
+ if (version >= 7 && alignmentProfileForFormat(decodedFormatVersion) !== alignmentProfile) {
2268
+ throw new Error(`Format v${decodedFormatVersion} uses a different alignment profile.`);
2269
+ }
1895
2270
  const declaredCellEncoding = cellEncodingFromFlags(flags);
1896
2271
  if (declaredCellEncoding !== cellEncoding) {
1897
2272
  throw new Error(`Header declares ${declaredCellEncoding}, not ${cellEncoding}.`);
@@ -1929,10 +2304,11 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
1929
2304
  return {
1930
2305
  ok: true,
1931
2306
  format: "QuadQR",
1932
- formatVersion: FORMAT_VERSION,
2307
+ formatVersion: decodedFormatVersion,
1933
2308
  version,
1934
2309
  size,
1935
2310
  alignmentPatterns: layout.alignments.length,
2311
+ alignmentProfile,
1936
2312
  maskId,
1937
2313
  rotation,
1938
2314
  flags,
@@ -1980,10 +2356,93 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
1980
2356
  throw new Error(`QuadQR decode failed. ${errors.join(" | ")}`);
1981
2357
  }
1982
2358
 
2359
+ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = null, options = {}) {
2360
+ const hinted = options.alignmentProfileHint;
2361
+ const profiles = hinted
2362
+ ? [hinted]
2363
+ : [ALIGNMENT_PROFILE_STANDARD_5, ALIGNMENT_PROFILE_LEGACY_3];
2364
+ const errors = [];
2365
+ for (const alignmentProfile of profiles) {
2366
+ try {
2367
+ return decodeCanonicalWithProfile(matrix, rotation, tolerance, confidenceMatrix, options, alignmentProfile);
2368
+ } catch (error) {
2369
+ errors.push(`${alignmentProfile}: ${error.message}`);
2370
+ }
2371
+ }
2372
+ throw new Error(`QuadQR decode failed for all alignment profiles. ${errors.join(" | ")}`);
2373
+ }
2374
+
2375
+ function trySoftMatrixDecode(matrix, alternatives, confidenceMatrix, degrees, tolerance, options = {}) {
2376
+ if (!alternatives || !confidenceMatrix || options.softDecoding === false) return null;
2377
+ const version = versionFromSize(matrix.length);
2378
+ if (!version) return null;
2379
+ const layout = createLayout(version);
2380
+ const legacyLayout = createLayout(version, { alignmentProfile: ALIGNMENT_PROFILE_LEGACY_3 });
2381
+ const softPositions = [];
2382
+ const seenSoftPositions = new Set();
2383
+ for (const [row, col] of [...layout.dataPositions, ...legacyLayout.dataPositions]) {
2384
+ const key = `${row},${col}`;
2385
+ if (seenSoftPositions.has(key)) continue;
2386
+ seenSoftPositions.add(key);
2387
+ softPositions.push([row, col]);
2388
+ }
2389
+ const threshold = clampNumber(options.softDecodeConfidence ?? 0.72, 0.05, 0.95);
2390
+ const maxCells = Math.max(2, Math.min(16, Math.round(options.softDecodeMaxCells ?? 10)));
2391
+ const pairCells = Math.max(2, Math.min(maxCells, Math.round(options.softDecodePairCells ?? 6)));
2392
+ const ranked = [];
2393
+
2394
+ for (const [row, col] of softPositions) {
2395
+ const alternative = alternatives[row]?.[col];
2396
+ const confidence = confidenceMatrix[row]?.[col] ?? 1;
2397
+ if (!Number.isInteger(alternative) || alternative === matrix[row][col] || confidence > threshold) continue;
2398
+ ranked.push({ row, col, alternative, confidence });
2399
+ }
2400
+ ranked.sort((a, b) => (a.confidence - b.confidence) || (a.row - b.row) || (a.col - b.col));
2401
+ const candidates = ranked.slice(0, maxCells);
2402
+ let attempts = 0;
2403
+
2404
+ const attempt = (changes) => {
2405
+ const trial = cloneMatrix(matrix);
2406
+ for (const change of changes) trial[change.row][change.col] = change.alternative;
2407
+ attempts++;
2408
+ try {
2409
+ const decoded = decodeCanonical(trial, degrees, tolerance, confidenceMatrix, {
2410
+ ...options,
2411
+ softDecoding: false
2412
+ });
2413
+ return {
2414
+ ...decoded,
2415
+ spectrumEccVersion: 2,
2416
+ softDecoded: true,
2417
+ softSubstitutions: changes.length,
2418
+ softDecodeAttempts: attempts,
2419
+ softDecodeCellsConsidered: candidates.length
2420
+ };
2421
+ } catch {
2422
+ return null;
2423
+ }
2424
+ };
2425
+
2426
+ for (const candidate of candidates) {
2427
+ const decoded = attempt([candidate]);
2428
+ if (decoded) return decoded;
2429
+ }
2430
+
2431
+ const pairCandidates = candidates.slice(0, pairCells);
2432
+ for (let i = 0; i < pairCandidates.length; i++) {
2433
+ for (let j = i + 1; j < pairCandidates.length; j++) {
2434
+ const decoded = attempt([pairCandidates[i], pairCandidates[j]]);
2435
+ if (decoded) return decoded;
2436
+ }
2437
+ }
2438
+ return null;
2439
+ }
2440
+
1983
2441
  export function decodeMatrix(inputMatrix, options = {}) {
1984
2442
  assert(Array.isArray(inputMatrix) && inputMatrix.length > 0, "Matrix is required.");
1985
2443
  let matrix = cloneMatrix(inputMatrix);
1986
2444
  let confidenceMatrix = options.cellConfidence ? cloneMatrix(options.cellConfidence) : null;
2445
+ let alternativeMatrix = options.cellAlternatives ? cloneMatrix(options.cellAlternatives) : null;
1987
2446
  const errors = [];
1988
2447
  const tolerance = options.structureTolerance ?? 0;
1989
2448
 
@@ -1993,16 +2452,38 @@ export function decodeMatrix(inputMatrix, options = {}) {
1993
2452
  "cellConfidence must be a square matrix matching the QuadQR matrix."
1994
2453
  );
1995
2454
  }
2455
+ if (alternativeMatrix) {
2456
+ assert(
2457
+ alternativeMatrix.length === matrix.length && alternativeMatrix.every((row) => row.length === matrix.length),
2458
+ "cellAlternatives must be a square matrix matching the QuadQR matrix."
2459
+ );
2460
+ }
1996
2461
 
1997
2462
  for (let rotationIndex = 0; rotationIndex < 4; rotationIndex++) {
1998
2463
  const degrees = rotationIndex * 90;
1999
2464
  try {
2000
- return decodeCanonical(matrix, degrees, tolerance, confidenceMatrix, options);
2465
+ const decoded = decodeCanonical(matrix, degrees, tolerance, confidenceMatrix, options);
2466
+ return {
2467
+ spectrumEccVersion: 2,
2468
+ softDecoded: false,
2469
+ softSubstitutions: 0,
2470
+ ...decoded
2471
+ };
2001
2472
  } catch (error) {
2002
2473
  errors.push(`${degrees}°: ${error.message}`);
2474
+ const softDecoded = trySoftMatrixDecode(
2475
+ matrix,
2476
+ alternativeMatrix,
2477
+ confidenceMatrix,
2478
+ degrees,
2479
+ tolerance,
2480
+ options
2481
+ );
2482
+ if (softDecoded) return softDecoded;
2003
2483
  }
2004
2484
  matrix = rotate90(matrix);
2005
2485
  if (confidenceMatrix) confidenceMatrix = rotate90(confidenceMatrix);
2486
+ if (alternativeMatrix) alternativeMatrix = rotate90(alternativeMatrix);
2006
2487
  }
2007
2488
 
2008
2489
  throw new Error(`Unable to decode matrix. ${errors.join(" || ")}`);
@@ -2944,11 +3425,96 @@ function makeWhiteBalanceTransform(observed) {
2944
3425
  });
2945
3426
  }
2946
3427
 
3428
+ function solveLinearSystem(matrix, vector) {
3429
+ const n = vector.length;
3430
+ const a = matrix.map((row, index) => row.slice().concat(vector[index]));
3431
+ for (let col = 0; col < n; col++) {
3432
+ let pivot = col;
3433
+ for (let row = col + 1; row < n; row++) {
3434
+ if (Math.abs(a[row][col]) > Math.abs(a[pivot][col])) pivot = row;
3435
+ }
3436
+ if (Math.abs(a[pivot][col]) < 1e-9) return null;
3437
+ [a[col], a[pivot]] = [a[pivot], a[col]];
3438
+ const divisor = a[col][col];
3439
+ for (let j = col; j <= n; j++) a[col][j] /= divisor;
3440
+ for (let row = 0; row < n; row++) {
3441
+ if (row === col) continue;
3442
+ const factor = a[row][col];
3443
+ if (Math.abs(factor) < 1e-12) continue;
3444
+ for (let j = col; j <= n; j++) a[row][j] -= factor * a[col][j];
3445
+ }
3446
+ }
3447
+ return a.map((row) => row[n]);
3448
+ }
3449
+
3450
+ function makeAffineCalibrationTransform(observed) {
3451
+ const ideal = {
3452
+ black: { r: 0, g: 0, b: 0 },
3453
+ white: { r: 255, g: 255, b: 255 },
3454
+ red: { r: 255, g: 0, b: 0 },
3455
+ green: { r: 0, g: 255, b: 0 },
3456
+ blue: { r: 0, g: 0, b: 255 }
3457
+ };
3458
+ const keys = ["black", "white", "red", "green", "blue"];
3459
+ const rows = keys.map((key) => {
3460
+ const rgb = observed[key];
3461
+ return [rgb.r, rgb.g, rgb.b, 1];
3462
+ });
3463
+ const normal = Array.from({ length: 4 }, () => Array(4).fill(0));
3464
+ for (const row of rows) {
3465
+ for (let i = 0; i < 4; i++) {
3466
+ for (let j = 0; j < 4; j++) normal[i][j] += row[i] * row[j];
3467
+ }
3468
+ }
3469
+ // A tiny ridge term keeps the transform stable if the photographed palette
3470
+ // becomes nearly singular under severe clipping or monochromatic lighting.
3471
+ const ridge = 1e-4;
3472
+ for (let i = 0; i < 4; i++) normal[i][i] += ridge;
3473
+
3474
+ const coefficients = {};
3475
+ for (const channel of ["r", "g", "b"]) {
3476
+ const rhs = Array(4).fill(0);
3477
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
3478
+ const x = rows[rowIndex];
3479
+ const y = ideal[keys[rowIndex]][channel];
3480
+ for (let i = 0; i < 4; i++) rhs[i] += x[i] * y;
3481
+ }
3482
+ coefficients[channel] = solveLinearSystem(normal, rhs);
3483
+ if (!coefficients[channel]) return null;
3484
+ }
3485
+
3486
+ const transform = (rgb) => {
3487
+ const x = [rgb.r, rgb.g, rgb.b, 1];
3488
+ const mapChannel = (channel) => coefficients[channel].reduce((sum, value, index) => sum + value * x[index], 0);
3489
+ return {
3490
+ r: clampNumber(mapChannel("r"), -96, 384),
3491
+ g: clampNumber(mapChannel("g"), -96, 384),
3492
+ b: clampNumber(mapChannel("b"), -96, 384)
3493
+ };
3494
+ };
3495
+
3496
+ let errorSq = 0;
3497
+ for (const key of keys) {
3498
+ const mapped = transform(observed[key]);
3499
+ const target = ideal[key];
3500
+ errorSq += colorDistanceSq(mapped, target);
3501
+ }
3502
+ return { transform, ideal, rmsError: Math.sqrt(errorSq / (keys.length * 3)) };
3503
+ }
3504
+
2947
3505
  function classifierFromPaletteRgb(observed, mode = "raw") {
2948
- const transform = mode === "balanced" || mode === "hue"
3506
+ const affine = mode === "affine" ? makeAffineCalibrationTransform(observed) : null;
3507
+ const transform = affine?.transform ?? (mode === "balanced" || mode === "hue"
2949
3508
  ? makeWhiteBalanceTransform(observed)
2950
- : identityColorTransform;
2951
- const entries = [
3509
+ : identityColorTransform);
3510
+ const idealEntries = affine ? [
3511
+ { cell: CELL.BLACK, rgb: affine.ideal.black },
3512
+ { cell: CELL.WHITE, rgb: affine.ideal.white },
3513
+ { cell: CELL.RED, rgb: affine.ideal.red },
3514
+ { cell: CELL.GREEN, rgb: affine.ideal.green },
3515
+ { cell: CELL.BLUE, rgb: affine.ideal.blue }
3516
+ ] : null;
3517
+ const entries = idealEntries ?? [
2952
3518
  { cell: CELL.BLACK, rgb: transform(observed.black) },
2953
3519
  { cell: CELL.WHITE, rgb: transform(observed.white) },
2954
3520
  { cell: CELL.RED, rgb: transform(observed.red) },
@@ -2962,6 +3528,8 @@ function classifierFromPaletteRgb(observed, mode = "raw") {
2962
3528
  entries,
2963
3529
  transform,
2964
3530
  mode,
3531
+ calibrationModel: affine ? "affine-3x4" : (mode === "balanced" || mode === "hue" ? "black-white-balance" : "observed-palette"),
3532
+ calibrationError: affine?.rmsError ?? null,
2965
3533
  dataMode: mode === "hue" ? "hue" : "distance",
2966
3534
  whiteChroma: chroma(entries.find(({ cell }) => cell === CELL.WHITE).rgb),
2967
3535
  minimumColorChroma
@@ -2975,27 +3543,30 @@ function colorDistanceSq(a, b) {
2975
3543
  return dr * dr + dg * dg + db * db;
2976
3544
  }
2977
3545
 
2978
- function classifyRgb(rgb, classifier) {
3546
+ function rankRgbCandidates(rgb, classifier) {
2979
3547
  const transformed = classifier.transform(rgb);
2980
- let best = null;
2981
- let bestDistanceSq = Infinity;
2982
- let secondDistanceSq = Infinity;
2983
-
2984
- for (const candidate of classifier.entries) {
2985
- const distanceSq = colorDistanceSq(transformed, candidate.rgb);
2986
- if (distanceSq < bestDistanceSq) {
2987
- secondDistanceSq = bestDistanceSq;
2988
- bestDistanceSq = distanceSq;
2989
- best = candidate;
2990
- } else if (distanceSq < secondDistanceSq) {
2991
- secondDistanceSq = distanceSq;
2992
- }
2993
- }
3548
+ return classifier.entries
3549
+ .map((candidate) => ({
3550
+ cell: candidate.cell,
3551
+ rgb: candidate.rgb,
3552
+ distance: Math.sqrt(colorDistanceSq(transformed, candidate.rgb))
3553
+ }))
3554
+ .sort((a, b) => (a.distance - b.distance) || (a.cell - b.cell));
3555
+ }
2994
3556
 
2995
- const distance = Math.sqrt(bestDistanceSq);
2996
- const secondDistance = Number.isFinite(secondDistanceSq) ? Math.sqrt(secondDistanceSq) : distance + 1;
2997
- const confidence = Math.max(0, Math.min(1, (secondDistance - distance) / Math.max(secondDistance, 1e-6)));
2998
- return { cell: best.cell, distance, confidence };
3557
+ function classifyRgb(rgb, classifier) {
3558
+ const ranked = rankRgbCandidates(rgb, classifier);
3559
+ const best = ranked[0];
3560
+ const second = ranked[1] ?? { cell: best.cell, distance: best.distance + 1 };
3561
+ const confidence = Math.max(0, Math.min(1, (second.distance - best.distance) / Math.max(second.distance, 1e-6)));
3562
+ return {
3563
+ cell: best.cell,
3564
+ distance: best.distance,
3565
+ confidence,
3566
+ alternativeCell: second.cell,
3567
+ alternativeDistance: second.distance,
3568
+ ambiguity: 1 - confidence
3569
+ };
2999
3570
  }
3000
3571
 
3001
3572
 
@@ -3031,6 +3602,7 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
3031
3602
  const size = rgbGrid.length;
3032
3603
  const matrix = make2D(size, CELL.WHITE);
3033
3604
  const confidence = make2D(size, 1);
3605
+ const alternatives = make2D(size, null);
3034
3606
  const dataClassifier = {
3035
3607
  ...classifier,
3036
3608
  entries: classifier.entries.filter(({ cell }) => cell !== CELL.BLACK)
@@ -3050,8 +3622,11 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
3050
3622
  const classified = isDataCell && classifier.dataMode === "hue"
3051
3623
  ? classifyDataHue(rgbGrid[r][c], classifier)
3052
3624
  : classifyRgb(rgbGrid[r][c], candidates);
3625
+ const ranked = rankRgbCandidates(rgbGrid[r][c], candidates);
3626
+ const alternate = ranked.find((candidate) => candidate.cell !== classified.cell);
3053
3627
  matrix[r][c] = classified.cell;
3054
3628
  confidence[r][c] = classified.confidence;
3629
+ alternatives[r][c] = alternate?.cell ?? null;
3055
3630
  distanceSum += classified.distance;
3056
3631
  confidenceSum += classified.confidence;
3057
3632
  minimumConfidence = Math.min(minimumConfidence, classified.confidence);
@@ -3061,6 +3636,7 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
3061
3636
  return {
3062
3637
  matrix,
3063
3638
  confidence,
3639
+ alternatives,
3064
3640
  averageColorDistance: distanceSum / (size * size),
3065
3641
  averageCellConfidence: confidenceSum / (size * size),
3066
3642
  minimumCellConfidence: minimumConfidence,
@@ -3072,6 +3648,7 @@ function classifyTriangleSampledRgbGrid(rgbGrid, triangleGrid, classifier, layou
3072
3648
  const size = rgbGrid.length;
3073
3649
  const matrix = make2D(size, CELL.WHITE);
3074
3650
  const confidence = make2D(size, 1);
3651
+ const alternatives = make2D(size, null);
3075
3652
  const dataClassifier = {
3076
3653
  ...classifier,
3077
3654
  entries: classifier.entries.filter(({ cell }) => cell !== CELL.BLACK)
@@ -3088,6 +3665,7 @@ function classifyTriangleSampledRgbGrid(rgbGrid, triangleGrid, classifier, layou
3088
3665
  const classified = classifyRgb(rgbGrid[r][c], classifier);
3089
3666
  matrix[r][c] = classified.cell;
3090
3667
  confidence[r][c] = classified.confidence;
3668
+ alternatives[r][c] = classified.alternativeCell ?? null;
3091
3669
  distanceSum += classified.distance;
3092
3670
  confidenceSum += classified.confidence;
3093
3671
  minimumConfidence = Math.min(minimumConfidence, classified.confidence);
@@ -3102,19 +3680,35 @@ function classifyTriangleSampledRgbGrid(rgbGrid, triangleGrid, classifier, layou
3102
3680
  : classifyRgb(rgb, dataClassifier);
3103
3681
  const first = classifyData(samples.first);
3104
3682
  const second = classifyData(samples.second);
3105
- const cellConfidence = Math.min(first.confidence, second.confidence);
3683
+ const firstRanked = rankRgbCandidates(samples.first, dataClassifier);
3684
+ const secondRanked = rankRgbCandidates(samples.second, dataClassifier);
3685
+ const firstAlt = firstRanked.find((candidate) => candidate.cell !== first.cell)?.cell ?? first.cell;
3686
+ const secondAlt = secondRanked.find((candidate) => candidate.cell !== second.cell)?.cell ?? second.cell;
3687
+ const firstSpread = Number(samples.firstSpread ?? 0);
3688
+ const secondSpread = Number(samples.secondSpread ?? 0);
3689
+ const stability = clampNumber(1 - Math.max(firstSpread, secondSpread) / 110, 0.35, 1);
3690
+ const cellConfidence = Math.min(first.confidence, second.confidence) * (0.68 + stability * 0.32);
3106
3691
  matrix[r][c] = packTriangleCell(first.cell, second.cell);
3107
- confidence[r][c] = cellConfidence;
3692
+ // Prefer changing the region with the weaker classification. If both are
3693
+ // similarly uncertain, changing the more spatially unstable region first
3694
+ // gives Spectrum ECC 2.0 a better second hypothesis.
3695
+ const firstRisk = (1 - first.confidence) + firstSpread / 255;
3696
+ const secondRisk = (1 - second.confidence) + secondSpread / 255;
3697
+ alternatives[r][c] = firstRisk >= secondRisk
3698
+ ? packTriangleCell(firstAlt, second.cell)
3699
+ : packTriangleCell(first.cell, secondAlt);
3700
+ confidence[r][c] = clampNumber(cellConfidence, 0, 1);
3108
3701
  distanceSum += (first.distance + second.distance) / 2;
3109
- confidenceSum += cellConfidence;
3110
- minimumConfidence = Math.min(minimumConfidence, cellConfidence);
3111
- if (cellConfidence < 0.4) lowConfidenceCells++;
3702
+ confidenceSum += confidence[r][c];
3703
+ minimumConfidence = Math.min(minimumConfidence, confidence[r][c]);
3704
+ if (confidence[r][c] < 0.4) lowConfidenceCells++;
3112
3705
  }
3113
3706
  }
3114
3707
 
3115
3708
  return {
3116
3709
  matrix,
3117
3710
  confidence,
3711
+ alternatives,
3118
3712
  averageColorDistance: distanceSum / (size * size),
3119
3713
  averageCellConfidence: confidenceSum / (size * size),
3120
3714
  minimumCellConfidence: minimumConfidence,
@@ -3144,32 +3738,37 @@ function paletteClassifierAttempts(observedPalette) {
3144
3738
  return [
3145
3739
  { classifier: classifierFromPaletteRgb(observedPalette, "raw"), colorNormalization: "observed-rgb" },
3146
3740
  { classifier: classifierFromPaletteRgb(observedPalette, "balanced"), colorNormalization: "white-balanced" },
3741
+ { classifier: classifierFromPaletteRgb(observedPalette, "affine"), colorNormalization: "affine-calibrated" },
3147
3742
  { classifier: classifierFromPaletteRgb(observedPalette, "hue"), colorNormalization: "white-balanced-hue" }
3148
3743
  ];
3149
3744
  }
3150
3745
 
3151
3746
  function tryPerspectiveScan(imageData, options) {
3152
- const geometryCandidates = detectCodeGeometry(imageData, {
3153
- minVersion: options.minVersion ?? MIN_VERSION,
3154
- maxVersion: options.maxVersion ?? MAX_VERSION,
3155
- maxCandidates: options.maxGeometryCandidates ?? 8,
3156
- finderRecovery: options.finderRecovery,
3157
- finderAutoColorBlackClip: options.finderAutoColorBlackClip,
3158
- finderAutoColorWhiteClip: options.finderAutoColorWhiteClip,
3159
- finderAutoColorHighlightPercentile: options.finderAutoColorHighlightPercentile,
3160
- finderAutoColorOutputHighlight: options.finderAutoColorOutputHighlight,
3161
- finderAutoColorAnalysisInset: options.finderAutoColorAnalysisInset,
3162
- finderAutoColorMinimumInputRange: options.finderAutoColorMinimumInputRange,
3163
- finderAutoColorTargetSamples: options.finderAutoColorTargetSamples,
3164
- preciseAlignment: options.preciseAlignment,
3165
- diagnostics: options._visionDiagnostics,
3166
- diagnosticLabel: options._diagnosticLabel ?? "normal"
3167
- });
3747
+ const geometryCandidates = Array.isArray(options._geometryCandidatesOverride) && options._geometryCandidatesOverride.length
3748
+ ? options._geometryCandidatesOverride
3749
+ : detectCodeGeometry(imageData, {
3750
+ minVersion: options.minVersion ?? MIN_VERSION,
3751
+ maxVersion: options.maxVersion ?? MAX_VERSION,
3752
+ maxCandidates: options.maxGeometryCandidates ?? 8,
3753
+ finderRecovery: options.finderRecovery,
3754
+ finderAutoColorBlackClip: options.finderAutoColorBlackClip,
3755
+ finderAutoColorWhiteClip: options.finderAutoColorWhiteClip,
3756
+ finderAutoColorHighlightPercentile: options.finderAutoColorHighlightPercentile,
3757
+ finderAutoColorOutputHighlight: options.finderAutoColorOutputHighlight,
3758
+ finderAutoColorAnalysisInset: options.finderAutoColorAnalysisInset,
3759
+ finderAutoColorMinimumInputRange: options.finderAutoColorMinimumInputRange,
3760
+ finderAutoColorTargetSamples: options.finderAutoColorTargetSamples,
3761
+ preciseAlignment: options.preciseAlignment,
3762
+ diagnostics: options._visionDiagnostics,
3763
+ diagnosticLabel: options._diagnosticLabel ?? "normal"
3764
+ });
3168
3765
  if (Array.isArray(options._geometryCollector)) options._geometryCollector.push(...geometryCandidates);
3169
3766
  const results = [];
3170
3767
 
3171
3768
  for (const geometry of geometryCandidates) {
3172
- const layout = createLayout(geometry.version);
3769
+ const layout = createLayout(geometry.version, {
3770
+ alignmentProfile: geometry.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5
3771
+ });
3173
3772
  const sampleProfiles = [{
3174
3773
  sampleMode: options.sampleMode ?? "cross",
3175
3774
  sampleRadius: options.sampleRadius ?? 0.16,
@@ -3206,7 +3805,7 @@ function tryPerspectiveScan(imageData, options) {
3206
3805
  continue;
3207
3806
  }
3208
3807
 
3209
- const tryAttempt = (attempt, rgbGrid, metadata = {}) => {
3808
+ const tryAttempt = (attempt, rgbGrid, metadata = {}, allowSoftDecoding = options.softDecoding !== false) => {
3210
3809
  const activeObservedPalette = metadata.observedPalette ?? observedPalette;
3211
3810
  const activeSamplingMode = metadata.samplingMode ?? profile.sampleMode;
3212
3811
  const classified = classifySampledRgbGrid(rgbGrid, attempt.classifier, layout);
@@ -3216,6 +3815,7 @@ function tryPerspectiveScan(imageData, options) {
3216
3815
  version: geometry.version,
3217
3816
  matrix: classified.matrix,
3218
3817
  confidence: classified.confidence,
3818
+ alternatives: classified.alternatives,
3219
3819
  geometry,
3220
3820
  observedPalette: activeObservedPalette,
3221
3821
  samplingMode: activeSamplingMode,
@@ -3229,6 +3829,10 @@ function tryPerspectiveScan(imageData, options) {
3229
3829
  const decoded = decodeMatrix(classified.matrix, {
3230
3830
  structureTolerance: options.structureTolerance ?? 0.18,
3231
3831
  cellConfidence: classified.confidence,
3832
+ cellAlternatives: classified.alternatives,
3833
+ cellEncodingHint: CELL_ENCODINGS.RGBW,
3834
+ alignmentProfileHint: geometry.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5,
3835
+ softDecoding: allowSoftDecoding,
3232
3836
  maxErasureConfidence: options.maxErasureConfidence
3233
3837
  });
3234
3838
  if (decoded.version !== geometry.version) return false;
@@ -3257,7 +3861,7 @@ function tryPerspectiveScan(imageData, options) {
3257
3861
  }
3258
3862
  };
3259
3863
 
3260
- const tryTriangleAttempt = (attempt, metadata = {}) => {
3864
+ const tryTriangleAttempt = (attempt, metadata = {}, allowSoftDecoding = options.softDecoding !== false) => {
3261
3865
  if (!triangleSampled?.triangleGrid) return false;
3262
3866
  const activeObservedPalette = metadata.observedPalette ?? observedPalette;
3263
3867
  const activeSamplingMode = metadata.samplingMode ?? `${profile.sampleMode}-triangle16`;
@@ -3273,6 +3877,7 @@ function tryPerspectiveScan(imageData, options) {
3273
3877
  version: geometry.version,
3274
3878
  matrix: classified.matrix,
3275
3879
  confidence: classified.confidence,
3880
+ alternatives: classified.alternatives,
3276
3881
  geometry,
3277
3882
  observedPalette: activeObservedPalette,
3278
3883
  samplingMode: activeSamplingMode,
@@ -3287,7 +3892,10 @@ function tryPerspectiveScan(imageData, options) {
3287
3892
  const decoded = decodeMatrix(classified.matrix, {
3288
3893
  structureTolerance: options.structureTolerance ?? 0.18,
3289
3894
  cellConfidence: classified.confidence,
3895
+ cellAlternatives: classified.alternatives,
3290
3896
  cellEncodingHint: CELL_ENCODINGS.TRIANGLE16,
3897
+ alignmentProfileHint: geometry.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5,
3898
+ softDecoding: allowSoftDecoding,
3291
3899
  maxErasureConfidence: options.maxErasureConfidence
3292
3900
  });
3293
3901
  if (decoded.version !== geometry.version) return false;
@@ -3316,29 +3924,46 @@ function tryPerspectiveScan(imageData, options) {
3316
3924
  // Fast path: preserve the original observed-RGB classifier first, then
3317
3925
  // try per-channel white balancing. Most clean frames stop here without
3318
3926
  // paying for the more expensive spatial normalization fallback.
3319
- for (const attempt of paletteClassifierAttempts(observedPalette)) {
3320
- if (tryAttempt(attempt, sampled.rgbGrid) || tryTriangleAttempt(attempt)) {
3927
+ const basePaletteAttempts = paletteClassifierAttempts(observedPalette);
3928
+ for (const attempt of basePaletteAttempts) {
3929
+ if (tryAttempt(attempt, sampled.rgbGrid, {}, false) || tryTriangleAttempt(attempt, {}, false)) {
3321
3930
  geometryDecoded = true;
3322
3931
  break;
3323
3932
  }
3324
3933
  }
3934
+ // Spectrum ECC soft decoding is intentionally deferred until every cheap
3935
+ // hard classifier has had a chance. This preserves the exact recovery
3936
+ // candidates while avoiding expensive second-hypothesis RS searches when
3937
+ // a later normal color model can decode the frame immediately.
3938
+ if (!geometryDecoded && options.softDecoding !== false) {
3939
+ for (const attempt of basePaletteAttempts) {
3940
+ if (tryAttempt(attempt, sampled.rgbGrid, {}, true) || tryTriangleAttempt(attempt, {}, true)) {
3941
+ geometryDecoded = true;
3942
+ break;
3943
+ }
3944
+ }
3945
+ }
3325
3946
 
3326
3947
  if (!geometryDecoded && options.spatialColorNormalization !== false) {
3327
3948
  try {
3328
3949
  const normalizedGrid = spatiallyNormalizeRgbGrid(sampled.rgbGrid, layout.calibration);
3329
3950
  const normalizedPalette = sampleObservedPalette(normalizedGrid, layout.calibration, { robust: true });
3330
- geometryDecoded = tryAttempt({
3951
+ const spatialAttempt = {
3331
3952
  classifier: classifierFromPaletteRgb(normalizedPalette, "raw"),
3332
3953
  colorNormalization: "spatial-white-balanced"
3333
- }, normalizedGrid);
3954
+ };
3955
+ geometryDecoded = tryAttempt(spatialAttempt, normalizedGrid, {}, false);
3956
+ if (!geometryDecoded && options.softDecoding !== false) {
3957
+ geometryDecoded = tryAttempt(spatialAttempt, normalizedGrid, {}, true);
3958
+ }
3334
3959
  } catch {
3335
3960
  // Continue to the recovery profile below.
3336
3961
  }
3337
3962
  }
3338
3963
 
3339
- // Very cheap Photoshop-like recovery on the already-sampled module grid.
3340
- // Normal camera frames never reach this path. The transform approximates
3341
- // Auto Color + Auto Tone + Auto Contrast and costs only O(moduleCount).
3964
+ // Lightweight QuadQR color recovery on the already-sampled module grid.
3965
+ // Normal camera frames never reach this path. The transform combines
3966
+ // QuadQR Auto Color + Auto Tone + Auto Contrast and costs only O(moduleCount).
3342
3967
  if (!geometryDecoded && options.autoEnhanceRecovery !== false) {
3343
3968
  try {
3344
3969
  const enhancedGrid = autoToneContrastColorRgbGrid(sampled.rgbGrid, {
@@ -3347,26 +3972,38 @@ function tryPerspectiveScan(imageData, options) {
3347
3972
  saturation: options.autoEnhanceSaturation
3348
3973
  });
3349
3974
  const enhancedPalette = sampleObservedPalette(enhancedGrid, layout.calibration, { robust: true });
3350
- for (const attempt of paletteClassifierAttempts(enhancedPalette)) {
3351
- const recoveredAttempt = {
3352
- ...attempt,
3353
- colorNormalization: `auto-tone-contrast-color/${attempt.colorNormalization}`
3354
- };
3975
+ const enhancedAttempts = paletteClassifierAttempts(enhancedPalette).map((attempt) => ({
3976
+ ...attempt,
3977
+ colorNormalization: `auto-tone-contrast-color/${attempt.colorNormalization}`
3978
+ }));
3979
+ for (const recoveredAttempt of enhancedAttempts) {
3355
3980
  if (tryAttempt(recoveredAttempt, enhancedGrid, {
3356
3981
  observedPalette: enhancedPalette,
3357
3982
  autoEnhanced: true,
3358
3983
  recoveryMode: "module-grid-auto-tone-contrast-color"
3359
- })) {
3984
+ }, false)) {
3360
3985
  geometryDecoded = true;
3361
3986
  break;
3362
3987
  }
3363
3988
  }
3989
+ if (!geometryDecoded && options.softDecoding !== false) {
3990
+ for (const recoveredAttempt of enhancedAttempts) {
3991
+ if (tryAttempt(recoveredAttempt, enhancedGrid, {
3992
+ observedPalette: enhancedPalette,
3993
+ autoEnhanced: true,
3994
+ recoveryMode: "module-grid-auto-tone-contrast-color"
3995
+ }, true)) {
3996
+ geometryDecoded = true;
3997
+ break;
3998
+ }
3999
+ }
4000
+ }
3364
4001
  } catch {
3365
4002
  // Continue to the QR-region pixel recovery below.
3366
4003
  }
3367
4004
  }
3368
4005
 
3369
- // Camera-specific Photoshop-style fallback. Enhancing the whole camera
4006
+ // Camera-specific QuadQR color-recovery fallback. Enhancing the whole camera
3370
4007
  // frame is often ineffective because dark surroundings, browser/UI
3371
4008
  // reflections and unrelated objects skew the histograms. Once finder
3372
4009
  // geometry is known, rectify only the QuadQR region, run Auto Tone /
@@ -3405,21 +4042,34 @@ function tryPerspectiveScan(imageData, options) {
3405
4042
  layout.calibration,
3406
4043
  { robust: true }
3407
4044
  );
3408
- for (const attempt of paletteClassifierAttempts(enhancedPalette)) {
3409
- const recoveredAttempt = {
3410
- ...attempt,
3411
- colorNormalization: `rectified-auto-tone-contrast-color/${attempt.colorNormalization}`
3412
- };
4045
+ const rectifiedAttempts = paletteClassifierAttempts(enhancedPalette).map((attempt) => ({
4046
+ ...attempt,
4047
+ colorNormalization: `rectified-auto-tone-contrast-color/${attempt.colorNormalization}`
4048
+ }));
4049
+ for (const recoveredAttempt of rectifiedAttempts) {
3413
4050
  if (tryAttempt(recoveredAttempt, enhancedSampled.rgbGrid, {
3414
4051
  observedPalette: enhancedPalette,
3415
4052
  samplingMode: "rectified-auto-enhance",
3416
4053
  autoEnhanced: true,
3417
4054
  recoveryMode: "rectified-auto-tone-contrast-color"
3418
- })) {
4055
+ }, false)) {
3419
4056
  geometryDecoded = true;
3420
4057
  break;
3421
4058
  }
3422
4059
  }
4060
+ if (!geometryDecoded && options.softDecoding !== false) {
4061
+ for (const recoveredAttempt of rectifiedAttempts) {
4062
+ if (tryAttempt(recoveredAttempt, enhancedSampled.rgbGrid, {
4063
+ observedPalette: enhancedPalette,
4064
+ samplingMode: "rectified-auto-enhance",
4065
+ autoEnhanced: true,
4066
+ recoveryMode: "rectified-auto-tone-contrast-color"
4067
+ }, true)) {
4068
+ geometryDecoded = true;
4069
+ break;
4070
+ }
4071
+ }
4072
+ }
3423
4073
  } catch {
3424
4074
  // Geometry refinement remains available as the final bounded fallback.
3425
4075
  }
@@ -3428,6 +4078,15 @@ function tryPerspectiveScan(imageData, options) {
3428
4078
  if (geometryDecoded) break;
3429
4079
  }
3430
4080
 
4081
+ // A decoded QuadQR has already passed structural validation, Spectrum ECC,
4082
+ // and the payload CRC. Continuing through every lower-ranked geometry after
4083
+ // that point used to spend most of the camera scan time proving the same
4084
+ // frame again. Return the first authenticated decode immediately. An opt-in
4085
+ // diagnostic mode can still collect every successful geometry if needed.
4086
+ if (geometryDecoded && options.collectAllGeometryResults !== true) {
4087
+ return results[results.length - 1] ?? null;
4088
+ }
4089
+
3431
4090
  // Slow-path geometry micro-refinement. Finder/alignment detection can be
3432
4091
  // correct while a soft-focus or smeared camera shifts the effective module
3433
4092
  // centres by a small fraction of a cell. Clean scans never reach this code.
@@ -3485,6 +4144,7 @@ function tryPerspectiveScan(imageData, options) {
3485
4144
  version: geometry.version,
3486
4145
  matrix: classified.matrix,
3487
4146
  confidence: classified.confidence,
4147
+ alternatives: classified.alternatives,
3488
4148
  geometry,
3489
4149
  observedPalette,
3490
4150
  samplingMode: "refined-center",
@@ -3523,6 +4183,7 @@ function tryPerspectiveScan(imageData, options) {
3523
4183
  version: geometry.version,
3524
4184
  matrix: triangleClassified.matrix,
3525
4185
  confidence: triangleClassified.confidence,
4186
+ alternatives: triangleClassified.alternatives,
3526
4187
  geometry,
3527
4188
  observedPalette,
3528
4189
  samplingMode: "refined-triangle16",
@@ -3570,7 +4231,9 @@ function tryPerspectiveScan(imageData, options) {
3570
4231
  const decoded = decodeMatrix(candidate.classified.matrix, {
3571
4232
  structureTolerance: options.structureTolerance ?? 0.18,
3572
4233
  cellConfidence: candidate.classified.confidence,
4234
+ cellAlternatives: candidate.classified.alternatives,
3573
4235
  cellEncodingHint: candidate.cellEncoding,
4236
+ alignmentProfileHint: geometry.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5,
3574
4237
  maxErasureConfidence: options.maxErasureConfidence
3575
4238
  });
3576
4239
  if (decoded.version !== geometry.version) continue;
@@ -3599,6 +4262,11 @@ function tryPerspectiveScan(imageData, options) {
3599
4262
  }
3600
4263
  }
3601
4264
  }
4265
+
4266
+
4267
+ if (geometryDecoded && options.collectAllGeometryResults !== true) {
4268
+ return results[results.length - 1] ?? null;
4269
+ }
3602
4270
  }
3603
4271
 
3604
4272
  results.sort((a, b) =>
@@ -3707,6 +4375,7 @@ function tryAxisAlignedScan(imageData, options) {
3707
4375
  version,
3708
4376
  matrix: classified.matrix,
3709
4377
  confidence: classified.confidence,
4378
+ alternatives: classified.alternatives,
3710
4379
  bounds,
3711
4380
  samplingMode: classifiedAttempt.samplingMode,
3712
4381
  colorNormalization: attempt.colorNormalization,
@@ -3718,6 +4387,7 @@ function tryAxisAlignedScan(imageData, options) {
3718
4387
  const decoded = decodeMatrix(classified.matrix, {
3719
4388
  structureTolerance: options.structureTolerance ?? 0.12,
3720
4389
  cellConfidence: classified.confidence,
4390
+ cellAlternatives: classified.alternatives,
3721
4391
  cellEncodingHint: classifiedAttempt.cellEncoding,
3722
4392
  maxErasureConfidence: options.maxErasureConfidence
3723
4393
  });
@@ -3770,6 +4440,31 @@ export const STRESS_PROFILES = Object.freeze([
3770
4440
  Object.freeze({ id: "downscale", label: "Downscale", type: "downscale", severity: 0.52, weight: 0.9 })
3771
4441
  ]);
3772
4442
 
4443
+
4444
+ export const RELIABILITY_PROFILES = Object.freeze([
4445
+ Object.freeze({ id: "clean", label: "Clean reference", category: "Baseline", type: "clean", severity: 0, weight: 2, suite: "quick" }),
4446
+ Object.freeze({ id: "blur", label: "Lens blur", category: "Optics", type: "blur", severity: 0.40, weight: 1, suite: "quick" }),
4447
+ Object.freeze({ id: "motion", label: "Motion blur", category: "Optics", type: "motion-blur", severity: 0.42, weight: 1, suite: "full" }),
4448
+ Object.freeze({ id: "dark", label: "Low light", category: "Exposure", type: "brightness-low", severity: 0.50, weight: 1, suite: "quick" }),
4449
+ Object.freeze({ id: "bright", label: "High exposure", category: "Exposure", type: "brightness-high", severity: 0.38, weight: 1, suite: "full" }),
4450
+ Object.freeze({ id: "shadow", label: "Gradient shadow", category: "Lighting", type: "shadow", severity: 0.58, weight: 1.2, suite: "quick" }),
4451
+ Object.freeze({ id: "glare", label: "Specular glare", category: "Lighting", type: "glare", severity: 0.34, weight: 1.1, suite: "full" }),
4452
+ Object.freeze({ id: "warm", label: "Warm color cast", category: "Color", type: "warm", severity: 0.62, weight: 1, suite: "quick" }),
4453
+ Object.freeze({ id: "cool", label: "Cool color cast", category: "Color", type: "cool", severity: 0.60, weight: 1, suite: "full" }),
4454
+ Object.freeze({ id: "contrast", label: "Contrast loss", category: "Color", type: "contrast-loss", severity: 0.46, weight: 1, suite: "full" }),
4455
+ Object.freeze({ id: "noise", label: "Sensor noise", category: "Sensor", type: "noise", severity: 0.42, weight: 0.9, suite: "full" }),
4456
+ Object.freeze({ id: "jpeg", label: "JPEG-like damage", category: "Resampling", type: "jpeg", severity: 0.52, weight: 0.8, suite: "full" }),
4457
+ Object.freeze({ id: "downscale", label: "Aggressive downscale", category: "Resampling", type: "downscale", severity: 0.55, weight: 1, suite: "quick" }),
4458
+ Object.freeze({ id: "perspective-2d", label: "Projective skew", category: "Perspective", type: "perspective", severity: 0.52, weight: 1.3, suite: "quick" }),
4459
+ Object.freeze({ id: "yaw-35", label: "3D yaw 35°", category: "Perspective", type: "perspective-3d", severity: 0.55, yawDegrees: 35, pitchDegrees: 0, rollDegrees: 0, weight: 1.4, suite: "quick" }),
4460
+ Object.freeze({ id: "pitch-30", label: "3D pitch 30°", category: "Perspective", type: "perspective-3d", severity: 0.50, yawDegrees: 0, pitchDegrees: 30, rollDegrees: 0, weight: 1.3, suite: "full" }),
4461
+ Object.freeze({ id: "z-rotation-55", label: "Z rotation 55°", category: "Perspective", type: "perspective-3d", severity: 0.45, yawDegrees: 0, pitchDegrees: 0, rollDegrees: 55, weight: 1.1, suite: "quick" }),
4462
+ Object.freeze({ id: "combined-3d", label: "Combined 3D tilt", category: "Perspective", type: "perspective-3d", severity: 0.62, yawDegrees: 30, pitchDegrees: 15, rollDegrees: 18, weight: 1.5, suite: "full" }),
4463
+ Object.freeze({ id: "yaw-55", label: "Extreme yaw 55°", category: "Extreme perspective", type: "perspective-3d", severity: 0.82, yawDegrees: 55, pitchDegrees: 0, rollDegrees: 0, weight: 1.5, suite: "extreme" }),
4464
+ Object.freeze({ id: "pitch-40", label: "Extreme pitch 40°", category: "Extreme perspective", type: "perspective-3d", severity: 0.78, yawDegrees: 0, pitchDegrees: 40, rollDegrees: 0, weight: 1.4, suite: "extreme" }),
4465
+ Object.freeze({ id: "z-rotation-75", label: "Z rotation 75°", category: "Extreme perspective", type: "perspective-3d", severity: 0.72, yawDegrees: 0, pitchDegrees: 0, rollDegrees: 75, weight: 1.1, suite: "extreme" })
4466
+ ]);
4467
+
3773
4468
  function cloneImageDataLike(imageData) {
3774
4469
  return { width: imageData.width, height: imageData.height, data: new Uint8ClampedArray(imageData.data) };
3775
4470
  }
@@ -3869,13 +4564,151 @@ function perspectiveStress(imageData, severity) {
3869
4564
  return { width, height, data: out };
3870
4565
  }
3871
4566
 
4567
+
4568
+ function fillWhiteRgba(data) {
4569
+ for (let i = 0; i < data.length; i += 4) {
4570
+ data[i] = 255;
4571
+ data[i + 1] = 255;
4572
+ data[i + 2] = 255;
4573
+ data[i + 3] = 255;
4574
+ }
4575
+ }
4576
+
4577
+ function projectPlaneQuad(width, height, options = {}) {
4578
+ const pitch = Number(options.pitchDegrees ?? options.pitch ?? 0) * Math.PI / 180;
4579
+ const yaw = Number(options.yawDegrees ?? options.yaw ?? 0) * Math.PI / 180;
4580
+ const roll = Number(options.rollDegrees ?? options.roll ?? 0) * Math.PI / 180;
4581
+ const cameraDistance = Math.max(1.8, Number(options.cameraDistance ?? 3.0));
4582
+ const fill = clampNumber(Number(options.fill ?? 0.84), 0.45, 0.94);
4583
+ const aspect = width / Math.max(1, height);
4584
+ // Point order intentionally follows TL, TR, BL, BR to match the
4585
+ // destination->source homography convention used throughout the scanner.
4586
+ const corners = [
4587
+ { x: -aspect, y: -1, z: 0 },
4588
+ { x: aspect, y: -1, z: 0 },
4589
+ { x: -aspect, y: 1, z: 0 },
4590
+ { x: aspect, y: 1, z: 0 }
4591
+ ];
4592
+ const cp = Math.cos(pitch), sp = Math.sin(pitch);
4593
+ const cy = Math.cos(yaw), sy = Math.sin(yaw);
4594
+ const cr = Math.cos(roll), sr = Math.sin(roll);
4595
+ const projected = corners.map((point) => {
4596
+ // Yaw around Y.
4597
+ const x1 = point.x * cy + point.z * sy;
4598
+ const z1 = -point.x * sy + point.z * cy;
4599
+ const y1 = point.y;
4600
+ // Pitch around X.
4601
+ const y2 = y1 * cp - z1 * sp;
4602
+ const z2 = y1 * sp + z1 * cp;
4603
+ const x2 = x1;
4604
+ // Roll around Z, i.e. in-plane camera/code rotation.
4605
+ const x3 = x2 * cr - y2 * sr;
4606
+ const y3 = x2 * sr + y2 * cr;
4607
+ const depth = Math.max(0.35, cameraDistance + z2);
4608
+ const perspective = cameraDistance / depth;
4609
+ return { x: x3 * perspective, y: y3 * perspective };
4610
+ });
4611
+ const minX = Math.min(...projected.map((point) => point.x));
4612
+ const maxX = Math.max(...projected.map((point) => point.x));
4613
+ const minY = Math.min(...projected.map((point) => point.y));
4614
+ const maxY = Math.max(...projected.map((point) => point.y));
4615
+ const projectedWidth = Math.max(0.001, maxX - minX);
4616
+ const projectedHeight = Math.max(0.001, maxY - minY);
4617
+ const scale = Math.min(width * fill / projectedWidth, height * fill / projectedHeight);
4618
+ const centerX = (minX + maxX) / 2;
4619
+ const centerY = (minY + maxY) / 2;
4620
+ return projected.map((point) => ({
4621
+ x: width / 2 + (point.x - centerX) * scale,
4622
+ y: height / 2 + (point.y - centerY) * scale
4623
+ }));
4624
+ }
4625
+
4626
+ function warpToQuad(imageData, destination) {
4627
+ const { width, height } = imageData;
4628
+ const source = [
4629
+ { x: 0, y: 0 },
4630
+ { x: width - 1, y: 0 },
4631
+ { x: 0, y: height - 1 },
4632
+ { x: width - 1, y: height - 1 }
4633
+ ];
4634
+ const inverse = computeHomography(source, destination);
4635
+ const out = new Uint8ClampedArray(width * height * 4);
4636
+ fillWhiteRgba(out);
4637
+ for (let y = 0; y < height; y++) {
4638
+ for (let x = 0; x < width; x++) {
4639
+ const src = projectPoint(inverse, x, y);
4640
+ if (src.x < 0 || src.y < 0 || src.x >= width || src.y >= height) continue;
4641
+ const rgba = sampleRgbaBilinear(imageData, src.x, src.y);
4642
+ const p = (y * width + x) * 4;
4643
+ out[p] = rgba[0];
4644
+ out[p + 1] = rgba[1];
4645
+ out[p + 2] = rgba[2];
4646
+ out[p + 3] = 255;
4647
+ }
4648
+ }
4649
+ return { width, height, data: out };
4650
+ }
4651
+
4652
+ function perspective3dStress(imageData, severity, options = {}) {
4653
+ const s = clampNumber(Number(severity), 0, 1);
4654
+ const destination = projectPlaneQuad(imageData.width, imageData.height, {
4655
+ pitchDegrees: options.pitchDegrees ?? (8 + s * 28),
4656
+ yawDegrees: options.yawDegrees ?? (12 + s * 43),
4657
+ rollDegrees: options.rollDegrees ?? (s * 18),
4658
+ cameraDistance: options.cameraDistance ?? 3,
4659
+ fill: options.fill ?? (0.88 - s * 0.06)
4660
+ });
4661
+ return warpToQuad(imageData, destination);
4662
+ }
4663
+
4664
+ function motionBlurImage(imageData, severity, options = {}) {
4665
+ const radius = Math.max(1, Math.round(Number(options.radius ?? (2 + severity * 5))));
4666
+ const slope = Number(options.slope ?? 0.35);
4667
+ const { width, height } = imageData;
4668
+ const out = new Uint8ClampedArray(width * height * 4);
4669
+ for (let y = 0; y < height; y++) {
4670
+ for (let x = 0; x < width; x++) {
4671
+ const p = (y * width + x) * 4;
4672
+ for (let ch = 0; ch < 3; ch++) {
4673
+ let sum = 0;
4674
+ let count = 0;
4675
+ for (let k = -radius; k <= radius; k++) {
4676
+ const sx = x + k;
4677
+ const sy = y + Math.round(k * slope);
4678
+ if (sx < 0 || sy < 0 || sx >= width || sy >= height) continue;
4679
+ sum += imageData.data[(sy * width + sx) * 4 + ch];
4680
+ count++;
4681
+ }
4682
+ out[p + ch] = count ? sum / count : imageData.data[p + ch];
4683
+ }
4684
+ out[p + 3] = 255;
4685
+ }
4686
+ }
4687
+ return { width, height, data: out };
4688
+ }
4689
+
4690
+ function deterministicNoise(x, y, channel) {
4691
+ let value = Math.imul((x + 1) ^ (channel * 374761393), 668265263) ^ Math.imul(y + 11, 2246822519);
4692
+ value = Math.imul(value ^ (value >>> 13), 1274126177);
4693
+ return ((value ^ (value >>> 16)) >>> 0) / 0xffffffff * 2 - 1;
4694
+ }
4695
+
3872
4696
  /** Apply one deterministic camera/print-style distortion to ImageData. */
3873
4697
  export function applyStressDistortion(imageData, type, severity = 0.5, options = {}) {
3874
4698
  assert(imageData?.data && imageData.width && imageData.height, "applyStressDistortion requires ImageData-like input.");
3875
4699
  const s = clampNumber(Number(severity), 0, 1);
3876
4700
  if (type === "clean") return cloneImageDataLike(imageData);
3877
4701
  if (type === "blur") return boxBlurImage(imageData, 1 + s * 2.2);
4702
+ if (type === "motion-blur") return motionBlurImage(imageData, s, options);
3878
4703
  if (type === "perspective") return perspectiveStress(imageData, s);
4704
+ if (type === "perspective-3d" || type === "rotate-z") {
4705
+ return perspective3dStress(imageData, s, {
4706
+ ...options,
4707
+ pitchDegrees: type === "rotate-z" ? 0 : options.pitchDegrees,
4708
+ yawDegrees: type === "rotate-z" ? 0 : options.yawDegrees,
4709
+ rollDegrees: type === "rotate-z" ? (options.rollDegrees ?? 20 + s * 70) : options.rollDegrees
4710
+ });
4711
+ }
3879
4712
  if (type === "downscale") {
3880
4713
  const scale = Math.max(0.20, 1 - s * 0.72);
3881
4714
  const small = resizeImageDataLike(imageData, imageData.width * scale, imageData.height * scale);
@@ -3904,7 +4737,22 @@ export function applyStressDistortion(imageData, type, severity = 0.5, options =
3904
4737
  value += block * s * 3;
3905
4738
  } else if (type === "warm") {
3906
4739
  if (ch === 0) value *= 1 + s * 0.12;
4740
+ if (ch === 1) value *= 1 + s * 0.03;
3907
4741
  if (ch === 2) value *= 1 - s * 0.22;
4742
+ } else if (type === "cool") {
4743
+ if (ch === 0) value *= 1 - s * 0.20;
4744
+ if (ch === 1) value *= 1 + s * 0.02;
4745
+ if (ch === 2) value *= 1 + s * 0.14;
4746
+ } else if (type === "noise") {
4747
+ value += deterministicNoise(x, y, ch) * (5 + s * 34);
4748
+ } else if (type === "glare") {
4749
+ const nx = (x / Math.max(1, width - 1) - 0.68) / 0.28;
4750
+ const ny = (y / Math.max(1, height - 1) - 0.30) / 0.20;
4751
+ const falloff = Math.exp(-(nx * nx + ny * ny) * 2.2);
4752
+ value += (255 - value) * falloff * s * 0.82;
4753
+ } else if (type === "gamma") {
4754
+ const gamma = 1 + (s - 0.5) * 1.2;
4755
+ value = 255 * Math.pow(value / 255, gamma);
3908
4756
  }
3909
4757
  data[p + ch] = clampNumber(Math.round(value), 0, 255);
3910
4758
  }
@@ -3974,16 +4822,122 @@ export function runImageStressTest(imageData, expected = {}, options = {}) {
3974
4822
  };
3975
4823
  }
3976
4824
 
3977
- /** Render and run the standard scanability torture suite. */
3978
- export function assessScanability(code, renderOptions = {}, options = {}) {
3979
- assert(code?.matrix, "assessScanability expects an encoded QuadQR object.");
3980
- const testImageSize = Math.max(240, Math.min(Number(options.testImageSize ?? 480), Number(renderOptions.imageSize ?? 720)));
3981
- const image = renderToImageData(code, { ...renderOptions, imageSize: testImageSize });
3982
- const report = runImageStressTest(image, { version: code.version, crc32: code.crc32 }, options);
3983
- const autoLogoSize = renderOptions.logo ? estimateSafeLogoSize(code, renderOptions) : null;
4825
+
4826
+ function reliabilityProfilesForSuite(suite = "full") {
4827
+ const rank = { quick: 0, full: 1, extreme: 2 };
4828
+ const requested = rank[suite] ?? rank.full;
4829
+ return RELIABILITY_PROFILES.filter((profile) => (rank[profile.suite] ?? 1) <= requested);
4830
+ }
4831
+
4832
+ /** Run the broader Reliability Lab suite and return category-level scores. */
4833
+ export function runReliabilityLab(imageData, expected = {}, options = {}) {
4834
+ const suite = options.suite ?? "full";
4835
+ const profiles = options.profiles ?? reliabilityProfilesForSuite(suite);
4836
+ const report = runImageStressTest(imageData, expected, { ...options, profiles });
4837
+ const categoryMap = new Map();
4838
+ for (const result of report.results) {
4839
+ const profile = profiles.find((item) => item.id === result.id) ?? {};
4840
+ const category = profile.category ?? "Other";
4841
+ const weight = Number(profile.weight ?? 1);
4842
+ if (!categoryMap.has(category)) categoryMap.set(category, { category, passedWeight: 0, totalWeight: 0, passed: 0, total: 0 });
4843
+ const entry = categoryMap.get(category);
4844
+ entry.totalWeight += weight;
4845
+ entry.total++;
4846
+ if (result.passed) {
4847
+ entry.passedWeight += weight;
4848
+ entry.passed++;
4849
+ }
4850
+ result.category = category;
4851
+ result.pitchDegrees = profile.pitchDegrees ?? null;
4852
+ result.yawDegrees = profile.yawDegrees ?? null;
4853
+ result.rollDegrees = profile.rollDegrees ?? null;
4854
+ }
4855
+ const categories = [...categoryMap.values()].map((entry) => ({
4856
+ category: entry.category,
4857
+ passed: entry.passed,
4858
+ total: entry.total,
4859
+ score: entry.totalWeight ? entry.passedWeight / entry.totalWeight * 100 : 0
4860
+ })).sort((a, b) => a.score - b.score || a.category.localeCompare(b.category));
3984
4861
  return {
3985
4862
  ...report,
3986
- version: code.version,
4863
+ suite,
4864
+ categories,
4865
+ weakestCategory: categories[0] ?? null
4866
+ };
4867
+ }
4868
+
4869
+ /** Sweep one 3D perspective axis to measure the largest passing angle. */
4870
+ export function runPerspectiveSweep(imageData, expected = {}, options = {}) {
4871
+ const axis = options.axis ?? "yaw";
4872
+ const defaultAngles = axis === "roll"
4873
+ ? [0, 25, 45, 60, 75]
4874
+ : [0, 15, 25, 35, 45, 55];
4875
+ const angles = (options.angles ?? defaultAngles).map(Number).filter(Number.isFinite);
4876
+ const results = [];
4877
+ let maxPassedAngle = null;
4878
+ for (const angle of angles) {
4879
+ const transform = {
4880
+ pitchDegrees: Number(options.pitchDegrees ?? 0),
4881
+ yawDegrees: Number(options.yawDegrees ?? 0),
4882
+ rollDegrees: Number(options.rollDegrees ?? 0),
4883
+ fill: options.fill ?? 0.84,
4884
+ cameraDistance: options.cameraDistance ?? 3
4885
+ };
4886
+ if (axis === "pitch") transform.pitchDegrees = angle;
4887
+ else if (axis === "roll" || axis === "z") transform.rollDegrees = angle;
4888
+ else transform.yawDegrees = angle;
4889
+ const distorted = applyStressDistortion(imageData, "perspective-3d", 0.5, transform);
4890
+ const started = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
4891
+ try {
4892
+ const decoded = scanImageData(distorted, {
4893
+ minVersion: expected.version ?? options.minVersion ?? MIN_VERSION,
4894
+ maxVersion: expected.version ?? options.maxVersion ?? MAX_VERSION,
4895
+ debug: false
4896
+ });
4897
+ const passed = expected.crc32 == null || decoded.crc32 === expected.crc32;
4898
+ if (passed) maxPassedAngle = Math.max(maxPassedAngle ?? angle, angle);
4899
+ results.push({
4900
+ angle,
4901
+ axis,
4902
+ passed,
4903
+ confidence: decoded.confidence ?? decoded.diagnostics?.confidence ?? null,
4904
+ correctedSymbols: decoded.correctedSymbols ?? 0,
4905
+ elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
4906
+ image: options.includeImages ? distorted : undefined,
4907
+ error: passed ? null : "Decoded payload did not match expected CRC."
4908
+ });
4909
+ } catch (error) {
4910
+ results.push({
4911
+ angle,
4912
+ axis,
4913
+ passed: false,
4914
+ confidence: 0,
4915
+ correctedSymbols: null,
4916
+ elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
4917
+ image: options.includeImages ? distorted : undefined,
4918
+ error: error.message
4919
+ });
4920
+ }
4921
+ }
4922
+ return {
4923
+ axis,
4924
+ maxPassedAngle,
4925
+ passed: results.filter((item) => item.passed).length,
4926
+ total: results.length,
4927
+ results
4928
+ };
4929
+ }
4930
+
4931
+ /** Render and run the standard scanability torture suite. */
4932
+ export function assessScanability(code, renderOptions = {}, options = {}) {
4933
+ assert(code?.matrix, "assessScanability expects an encoded QuadQR object.");
4934
+ const testImageSize = Math.max(240, Math.min(Number(options.testImageSize ?? 480), Number(renderOptions.imageSize ?? 720)));
4935
+ const image = renderToImageData(code, { ...renderOptions, imageSize: testImageSize });
4936
+ const report = runImageStressTest(image, { version: code.version, crc32: code.crc32 }, options);
4937
+ const autoLogoSize = renderOptions.logo ? estimateSafeLogoSize(code, renderOptions) : null;
4938
+ return {
4939
+ ...report,
4940
+ version: code.version,
3987
4941
  eccLevel: code.eccLevel,
3988
4942
  utilization: code.utilization,
3989
4943
  testImageSize,
@@ -4135,6 +5089,38 @@ export function scanImageData(imageData, options = {}) {
4135
5089
  };
4136
5090
 
4137
5091
  if (options.perspective !== false) {
5092
+ const geometryHints = Array.isArray(options._geometryHints)
5093
+ ? options._geometryHints.filter((item) => item?.homography && Number.isInteger(item.version)).slice(0, 2)
5094
+ : [];
5095
+ if (geometryHints.length) {
5096
+ try {
5097
+ const hinted = tryPerspectiveScan(imageData, {
5098
+ ...scanOptions,
5099
+ _diagnosticLabel: options._diagnosticLabel ? `${options._diagnosticLabel}-geometry-reuse` : "geometry-reuse",
5100
+ _geometryCandidatesOverride: geometryHints,
5101
+ _geometryCollector: [],
5102
+ _observationCollector: [],
5103
+ adaptiveSampling: options._geometryReuseAdaptiveSampling ?? false,
5104
+ geometryRefinement: false
5105
+ });
5106
+ if (hinted) {
5107
+ geometryCollector.push(hinted.geometry ?? geometryHints[0]);
5108
+ return decorateScanResult({
5109
+ ...hinted,
5110
+ geometryReused: true,
5111
+ recoveryMode: hinted.recoveryMode ?? "geometry-reuse"
5112
+ }, debugContext, options);
5113
+ }
5114
+ } catch {
5115
+ // A stale geometry hint is expected when the camera/code moves. The
5116
+ // camera worker can request a hint-only attempt when it is reusing
5117
+ // low-resolution locator geometry on a higher-detail frame.
5118
+ }
5119
+ if (options._geometryHintOnly === true) {
5120
+ throw new Error("Geometry hint did not decode this frame.");
5121
+ }
5122
+ }
5123
+
4138
5124
  const perspective = tryPerspectiveScan(imageData, scanOptions);
4139
5125
  if (perspective) return decorateScanResult(perspective, debugContext, options);
4140
5126
  }
@@ -4460,6 +5446,21 @@ export function scanVideoFrame(video, options = {}) {
4460
5446
  _visionDiagnostics: visionDiagnostics
4461
5447
  });
4462
5448
  normalizeFrameDiagnostics(options._frameDiagnostics, source, width, height, visionDiagnostics);
5449
+ if (options._frameDiagnostics && result?.geometry && !options._frameDiagnostics.geometry) {
5450
+ const geometry = result.geometry;
5451
+ options._frameDiagnostics.geometry = geometry;
5452
+ options._frameDiagnostics.geometryReused = Boolean(result.geometryReused);
5453
+ if (geometry.finders) {
5454
+ const reusedFinders = [
5455
+ geometry.finders.topLeft,
5456
+ geometry.finders.topRight,
5457
+ geometry.finders.bottomLeft
5458
+ ].filter(Boolean);
5459
+ options._frameDiagnostics.finders = reusedFinders;
5460
+ options._frameDiagnostics.finderCount = reusedFinders.length;
5461
+ if (result.geometryReused) options._frameDiagnostics.finderMethod = "geometry-reuse";
5462
+ }
5463
+ }
4463
5464
  if (!source.cropped) return result;
4464
5465
  return {
4465
5466
  ...result,
@@ -4483,47 +5484,111 @@ function selectBestFrameObservation(observations) {
4483
5484
  )[0] ?? null;
4484
5485
  }
4485
5486
 
5487
+ function observationDataAgreement(a, b) {
5488
+ if (!a?.matrix || !b?.matrix || a.version !== b.version || a.matrix.length !== b.matrix.length) return 0;
5489
+ if ((a.cellEncoding ?? null) !== (b.cellEncoding ?? null)) return 0;
5490
+ const layout = createLayout(a.version);
5491
+ let agreed = 0;
5492
+ let compared = 0;
5493
+ for (const [row, col] of layout.dataPositions) {
5494
+ const ca = a.confidence?.[row]?.[col] ?? 0;
5495
+ const cb = b.confidence?.[row]?.[col] ?? 0;
5496
+ if (Math.min(ca, cb) < 0.48) continue;
5497
+ compared++;
5498
+ if (a.matrix[row][col] === b.matrix[row][col]) agreed++;
5499
+ }
5500
+ return compared ? agreed / compared : 0;
5501
+ }
5502
+
4486
5503
  function combineFrameObservations(observations) {
4487
5504
  if (!observations?.length) return null;
4488
5505
  const version = observations[0].version;
5506
+ const cellEncoding = observations[0].cellEncoding ?? null;
4489
5507
  const size = observations[0].matrix.length;
4490
- if (!observations.every((item) => item.version === version && item.matrix.length === size)) return null;
5508
+ if (!observations.every((item) =>
5509
+ item.version === version &&
5510
+ (item.cellEncoding ?? null) === cellEncoding &&
5511
+ item.matrix.length === size
5512
+ )) return null;
4491
5513
 
4492
5514
  const matrix = make2D(size, CELL.WHITE);
4493
5515
  const confidence = make2D(size, 0);
5516
+ const alternatives = make2D(size, null);
5517
+ const frameCount = observations.length;
5518
+ const latest = observations[frameCount - 1];
5519
+ let agreementSum = 0;
5520
+ let agreementCount = 0;
5521
+ for (let i = 0; i < frameCount - 1; i++) {
5522
+ const agreement = observationDataAgreement(observations[i], latest);
5523
+ if (agreement > 0) {
5524
+ agreementSum += agreement;
5525
+ agreementCount++;
5526
+ }
5527
+ }
5528
+
4494
5529
  for (let r = 0; r < size; r++) {
4495
5530
  for (let c = 0; c < size; c++) {
4496
5531
  const votes = new Map();
4497
5532
  const confidenceByCell = new Map();
4498
- for (const observation of observations) {
5533
+ for (let index = 0; index < observations.length; index++) {
5534
+ const observation = observations[index];
4499
5535
  const cell = observation.matrix[r][c];
4500
5536
  const sourceConfidence = clampNumber(observation.confidence?.[r]?.[c] ?? 0.5, 0, 1);
4501
- const weight = 0.35 + sourceConfidence * 0.65;
5537
+ const age = observations.length - 1 - index;
5538
+ const recency = Math.pow(0.88, age);
5539
+ const frameQuality = clampNumber(
5540
+ 0.45 +
5541
+ (observation.structureScore ?? 0.82) * 0.30 +
5542
+ (observation.averageCellConfidence ?? 0.5) * 0.25,
5543
+ 0.45,
5544
+ 1
5545
+ );
5546
+ const weight = (0.25 + sourceConfidence * 0.75) * recency * frameQuality;
4502
5547
  votes.set(cell, (votes.get(cell) ?? 0) + weight);
4503
5548
  const stats = confidenceByCell.get(cell) ?? { weighted: 0, weight: 0 };
4504
5549
  stats.weighted += sourceConfidence * weight;
4505
5550
  stats.weight += weight;
4506
5551
  confidenceByCell.set(cell, stats);
5552
+
5553
+ // The per-frame second hypothesis also contributes weak evidence. It
5554
+ // never outranks a strong primary vote by itself, but it preserves a
5555
+ // plausible alternative for Spectrum ECC 2.0 when several frames are
5556
+ // individually ambiguous in the same region.
5557
+ const alternative = observation.alternatives?.[r]?.[c];
5558
+ if (Number.isInteger(alternative) && alternative !== cell) {
5559
+ const altWeight = weight * (1 - sourceConfidence) * 0.42;
5560
+ votes.set(alternative, (votes.get(alternative) ?? 0) + altWeight);
5561
+ }
4507
5562
  }
4508
5563
 
4509
5564
  const ranked = Array.from(votes.entries()).sort((a, b) => b[1] - a[1]);
4510
5565
  const [bestCell, bestWeight] = ranked[0];
4511
- const secondWeight = ranked[1]?.[1] ?? 0;
5566
+ const [secondCell, secondWeight] = ranked[1] ?? [null, 0];
4512
5567
  const totalWeight = ranked.reduce((sum, item) => sum + item[1], 0);
4513
5568
  const sourceStats = confidenceByCell.get(bestCell);
4514
5569
  const sourceConfidence = sourceStats?.weight ? sourceStats.weighted / sourceStats.weight : 0.5;
4515
5570
  const support = totalWeight ? bestWeight / totalWeight : 0;
4516
5571
  const margin = totalWeight ? (bestWeight - secondWeight) / totalWeight : 0;
4517
- const agreement = 0.5 * support + 0.5 * margin;
5572
+ const temporalBoost = clampNumber((frameCount - 1) / 5, 0, 0.18);
5573
+ const fusedConfidence = clampNumber(
5574
+ sourceConfidence * 0.50 + support * 0.30 + margin * 0.20 + temporalBoost,
5575
+ 0,
5576
+ 1
5577
+ );
4518
5578
 
4519
5579
  matrix[r][c] = bestCell;
4520
- // Do not become overconfident just because several blurry frames agree.
4521
- // Retaining source uncertainty lets Reed-Solomon treat repeated ambiguous
4522
- // cells as erasures instead of hard, supposedly-certain errors.
4523
- confidence[r][c] = clampNumber(sourceConfidence * 0.58 + agreement * 0.42, 0, 1);
5580
+ confidence[r][c] = fusedConfidence;
5581
+ alternatives[r][c] = Number.isInteger(secondCell) && secondCell !== bestCell ? secondCell : null;
4524
5582
  }
4525
5583
  }
4526
- return { version, matrix, confidence };
5584
+ return {
5585
+ version,
5586
+ cellEncoding,
5587
+ matrix,
5588
+ confidence,
5589
+ alternatives,
5590
+ frameAgreement: agreementCount ? agreementSum / agreementCount : 1
5591
+ };
4527
5592
  }
4528
5593
 
4529
5594
  async function improveCameraTrack(stream) {
@@ -4548,7 +5613,7 @@ async function improveCameraTrack(stream) {
4548
5613
  }
4549
5614
  }
4550
5615
 
4551
- export async function startCameraScanner(video, options = {}) {
5616
+ async function startCameraScannerMainThread(video, options = {}) {
4552
5617
  assert(typeof navigator !== "undefined" && navigator.mediaDevices?.getUserMedia, "Camera API is unavailable.");
4553
5618
  assert(video, "A video element is required.");
4554
5619
 
@@ -4557,8 +5622,8 @@ export async function startCameraScanner(video, options = {}) {
4557
5622
  audio: false,
4558
5623
  video: {
4559
5624
  facingMode: { ideal: "environment" },
4560
- width: { ideal: 1920 },
4561
- height: { ideal: 1080 },
5625
+ width: { ideal: 1280 },
5626
+ height: { ideal: 720 },
4562
5627
  frameRate: { ideal: 30 }
4563
5628
  }
4564
5629
  }
@@ -4570,7 +5635,11 @@ export async function startCameraScanner(video, options = {}) {
4570
5635
  video.muted = true;
4571
5636
  await video.play();
4572
5637
 
4573
- const scanInterval = Math.max(80, options.scanInterval ?? 180);
5638
+ // Keep scanning responsive without queueing stale camera frames. The old
5639
+ // 180 ms post-scan timeout made effective cadence equal scan time + 180 ms.
5640
+ // The scheduler below measures from scan start and uses real video frames
5641
+ // when requestVideoFrameCallback() is available.
5642
+ const scanInterval = Math.max(24, Number(options.scanInterval ?? 80));
4574
5643
  const scratchCanvas = document.createElement("canvas");
4575
5644
  const highResolutionCanvas = document.createElement("canvas");
4576
5645
  const multiFrameEnabled = options.multiFrame !== false;
@@ -4581,16 +5650,23 @@ export async function startCameraScanner(video, options = {}) {
4581
5650
  const cameraAutoEnhanceEvery = Math.max(1, Math.round(options.cameraAutoEnhanceEvery ?? 2));
4582
5651
  const cameraFinderRecoveryEvery = Math.max(1, Math.round(options.cameraFinderRecoveryEvery ?? 2));
4583
5652
  const cameraHighResolutionEvery = Math.max(1, Math.round(options.cameraHighResolutionEvery ?? 2));
4584
- const baseCameraMaxDimension = Math.max(480, Math.round(options.maxDimension ?? 1080));
5653
+ const baseCameraMaxDimension = Math.max(480, Math.round(options.maxDimension ?? 640));
4585
5654
  const cameraHighResolutionMaxDimension = Math.max(
4586
5655
  baseCameraMaxDimension,
4587
- Math.round(options.cameraHighResolutionMaxDimension ?? 1600)
5656
+ Math.round(options.cameraHighResolutionMaxDimension ?? 960)
4588
5657
  );
4589
5658
  let missStreak = 0;
4590
5659
  let stopped = false;
4591
5660
  let busy = false;
4592
5661
  let timer = null;
5662
+ let frameCallbackId = null;
5663
+ let lastScanStartedAt = -Infinity;
4593
5664
  let frameNumber = 0;
5665
+ let cameraGeometryHint = null;
5666
+ let cameraGeometryHintMisses = 0;
5667
+ const cameraGeometryReuseMaxMisses = Math.max(1, Math.round(options.cameraGeometryReuseMaxMisses ?? 5));
5668
+ const useVideoFrameCallback = options.useVideoFrameCallback !== false &&
5669
+ typeof video.requestVideoFrameCallback === "function";
4594
5670
 
4595
5671
  const diagnosticsEnabled = typeof options.onDiagnostic === "function";
4596
5672
  const emitDiagnostic = (event) => {
@@ -4626,6 +5702,12 @@ export async function startCameraScanner(video, options = {}) {
4626
5702
  const stop = () => {
4627
5703
  stopped = true;
4628
5704
  if (timer) clearTimeout(timer);
5705
+ if (frameCallbackId != null && typeof video.cancelVideoFrameCallback === "function") {
5706
+ try { video.cancelVideoFrameCallback(frameCallbackId); } catch {}
5707
+ }
5708
+ timer = null;
5709
+ frameCallbackId = null;
5710
+ cameraGeometryHint = null;
4629
5711
  observationHistory.clear();
4630
5712
  for (const track of stream.getTracks()) track.stop();
4631
5713
  if (video.srcObject === stream) video.srcObject = null;
@@ -4634,9 +5716,30 @@ export async function startCameraScanner(video, options = {}) {
4634
5716
  const scanNow = () => scanVideoFrame(video, {
4635
5717
  ...options,
4636
5718
  maxDimension: baseCameraMaxDimension,
4637
- canvas: scratchCanvas
5719
+ canvas: scratchCanvas,
5720
+ _geometryHints: options.cameraGeometryReuse === false || !cameraGeometryHint ? undefined : [cameraGeometryHint]
4638
5721
  });
4639
5722
 
5723
+ const updateCameraGeometryHint = (frameDiagnostics, result = null) => {
5724
+ if (options.cameraGeometryReuse === false) {
5725
+ cameraGeometryHint = null;
5726
+ return;
5727
+ }
5728
+ const geometry = result?.geometry ?? frameDiagnostics?.geometry ?? null;
5729
+ if (geometry?.homography && Number.isInteger(geometry.version)) {
5730
+ cameraGeometryHint = geometry;
5731
+ cameraGeometryHintMisses = 0;
5732
+ return;
5733
+ }
5734
+ if (cameraGeometryHint) {
5735
+ cameraGeometryHintMisses++;
5736
+ if (cameraGeometryHintMisses >= cameraGeometryReuseMaxMisses) {
5737
+ cameraGeometryHint = null;
5738
+ cameraGeometryHintMisses = 0;
5739
+ }
5740
+ }
5741
+ };
5742
+
4640
5743
  const emitResult = (result, capturedFrame = null, diagnostic = null) => {
4641
5744
  const frameMeta = capturedFrame?.imageData ? {
4642
5745
  frame: frameNumber,
@@ -4662,10 +5765,25 @@ export async function startCameraScanner(video, options = {}) {
4662
5765
  if (!multiFrameEnabled) return null;
4663
5766
  const best = selectBestFrameObservation(observations);
4664
5767
  if (!best) return null;
4665
- const history = observationHistory.get(best.version) ?? [];
5768
+ const trackKey = `${best.version}:${best.cellEncoding ?? "auto"}`;
5769
+ let history = observationHistory.get(trackKey) ?? [];
5770
+
5771
+ // Only fuse observations that appear to describe the same payload. Finder
5772
+ // structure alone is not enough because two different QuadQR symbols of the
5773
+ // same version would otherwise contaminate each other's history. High-
5774
+ // confidence data-cell agreement gives us a cheap identity check without
5775
+ // needing the header to decode first.
5776
+ if (history.length) {
5777
+ const agreement = observationDataAgreement(history[history.length - 1], best);
5778
+ const minimumAgreement = best.cellEncoding === CELL_ENCODINGS.TRIANGLE16
5779
+ ? (options.multiFrameMinAgreementHighDensity ?? 0.58)
5780
+ : (options.multiFrameMinAgreement ?? 0.62);
5781
+ if (agreement > 0 && agreement < minimumAgreement) history = [];
5782
+ }
5783
+
4666
5784
  history.push(best);
4667
5785
  while (history.length > multiFrameWindow) history.shift();
4668
- observationHistory.set(best.version, history);
5786
+ observationHistory.set(trackKey, history);
4669
5787
  if (history.length < multiFrameMinFrames) return null;
4670
5788
 
4671
5789
  const combined = combineFrameObservations(history);
@@ -4674,16 +5792,21 @@ export async function startCameraScanner(video, options = {}) {
4674
5792
  const decoded = decodeMatrix(combined.matrix, {
4675
5793
  structureTolerance: options.structureTolerance ?? 0.20,
4676
5794
  cellConfidence: combined.confidence,
4677
- maxErasureConfidence: options.maxErasureConfidence
5795
+ cellAlternatives: combined.alternatives,
5796
+ cellEncodingHint: best.cellEncoding ?? undefined,
5797
+ maxErasureConfidence: options.maxErasureConfidence,
5798
+ softDecoding: options.softDecoding
4678
5799
  });
4679
5800
  if (decoded.version !== best.version) return null;
4680
5801
  return {
4681
5802
  ...decoded,
4682
5803
  perspectiveCorrected: Boolean(best.geometry),
4683
5804
  colorCalibrated: true,
4684
- colorNormalization: "multi-frame-vote",
4685
- samplingMode: "multi-frame-vote",
5805
+ colorNormalization: "multi-frame-confidence-fusion",
5806
+ samplingMode: "multi-frame-confidence-fusion",
4686
5807
  multiFrameCombined: history.length,
5808
+ multiFrameAgreement: combined.frameAgreement,
5809
+ multiFrameMode: "confidence-fusion",
4687
5810
  geometry: best.geometry,
4688
5811
  observedPalette: best.observedPalette,
4689
5812
  averageCellConfidence: best.averageCellConfidence,
@@ -4694,6 +5817,33 @@ export async function startCameraScanner(video, options = {}) {
4694
5817
  }
4695
5818
  };
4696
5819
 
5820
+ const scheduleNextScan = () => {
5821
+ if (stopped) return;
5822
+ const runWhenDue = () => {
5823
+ frameCallbackId = null;
5824
+ if (stopped) return;
5825
+ const remaining = scanInterval - (nowMs() - lastScanStartedAt);
5826
+ if (remaining > 1) {
5827
+ timer = setTimeout(() => {
5828
+ timer = null;
5829
+ scheduleNextScan();
5830
+ }, remaining);
5831
+ return;
5832
+ }
5833
+ void loop();
5834
+ };
5835
+
5836
+ if (useVideoFrameCallback) {
5837
+ frameCallbackId = video.requestVideoFrameCallback(runWhenDue);
5838
+ } else {
5839
+ const remaining = Math.max(0, scanInterval - (nowMs() - lastScanStartedAt));
5840
+ timer = setTimeout(() => {
5841
+ timer = null;
5842
+ runWhenDue();
5843
+ }, remaining);
5844
+ }
5845
+ };
5846
+
4697
5847
  const loop = async () => {
4698
5848
  if (stopped) return;
4699
5849
  if (!busy && video.readyState >= 2) {
@@ -4703,6 +5853,7 @@ export async function startCameraScanner(video, options = {}) {
4703
5853
  const frameDiagnostics = {};
4704
5854
  const capturedFrame = {};
4705
5855
  const frameStarted = nowMs();
5856
+ lastScanStartedAt = frameStarted;
4706
5857
  let allowAutoEnhance = false;
4707
5858
  let allowFinderRecovery = false;
4708
5859
  try {
@@ -4730,11 +5881,13 @@ export async function startCameraScanner(video, options = {}) {
4730
5881
  fullFrameAutoEnhanceRecovery: options.fullFrameAutoEnhanceRecovery ?? false,
4731
5882
  maxDimension: baseCameraMaxDimension,
4732
5883
  canvas: scratchCanvas,
5884
+ _geometryHints: options.cameraGeometryReuse === false || !cameraGeometryHint ? undefined : [cameraGeometryHint],
4733
5885
  _capturedFrame: capturedFrame,
4734
5886
  _observationCollector: observations,
4735
5887
  _frameDiagnostics: frameDiagnostics
4736
5888
  });
4737
5889
  const elapsedMs = nowMs() - frameStarted;
5890
+ updateCameraGeometryHint(frameDiagnostics, result);
4738
5891
  emitDiagnostic({
4739
5892
  type: "frame",
4740
5893
  state: "decoded",
@@ -4756,6 +5909,7 @@ export async function startCameraScanner(video, options = {}) {
4756
5909
  if (emitResult(result, capturedFrame, frameDiagnostics)) return;
4757
5910
  } catch (error) {
4758
5911
  missStreak++;
5912
+ updateCameraGeometryHint(frameDiagnostics);
4759
5913
  const fastElapsedMs = nowMs() - frameStarted;
4760
5914
  emitDiagnostic({
4761
5915
  type: "frame",
@@ -4771,7 +5925,7 @@ export async function startCameraScanner(video, options = {}) {
4771
5925
 
4772
5926
  // Geometry-aware high-resolution retry. A dense QuadQR can look large
4773
5927
  // enough in the preview while each individual module has become too
4774
- // small after the normal 1080 px scanner cap. If the fast pass already
5928
+ // small after the normal 640 px scanner cap. If the fast pass already
4775
5929
  // sees at least two convincing finders, spend one bounded retry on a
4776
5930
  // higher-resolution copy of the visible camera ROI. Empty frames and
4777
5931
  // ordinary small codes never pay this cost.
@@ -4834,10 +5988,9 @@ export async function startCameraScanner(video, options = {}) {
4834
5988
  }
4835
5989
  }
4836
5990
 
4837
- // The user's real phone-camera case is dominated by color cast before
4838
- // finder detection: Photoshop Auto Color alone makes the same live QR
4839
- // immediately detectable. Retry the exact captured frame with a cheap
4840
- // per-channel Auto Color levels correction before any geometry-dependent
5991
+ // Strong color casts can hide finder structure before normal geometry
5992
+ // recovery begins. QuadQR Auto Color retries the exact captured frame with a cheap
5993
+ // per-channel QuadQR Auto Color levels correction before any geometry-dependent
4841
5994
  // recovery. This runs only after the normal fast scan fails, and by
4842
5995
  // default only on every other missed frame after the first one.
4843
5996
  const shouldTryCameraAutoColor = options.cameraAutoColorRecovery !== false &&
@@ -4860,7 +6013,7 @@ export async function startCameraScanner(video, options = {}) {
4860
6013
  type: "method",
4861
6014
  state: "trying",
4862
6015
  method: "camera-auto-color",
4863
- message: `Fast scan failed · Auto Color recovery inside camera guide (${cropInsets.map((v) => v ? `${Math.round(v * 100)}% crop` : "full frame").join(" → ")})`,
6016
+ message: `Fast scan failed · QuadQR Auto Color recovery inside camera guide (${cropInsets.map((v) => v ? `${Math.round(v * 100)}% crop` : "full frame").join(" → ")})`,
4864
6017
  ...frameDiagnostics
4865
6018
  });
4866
6019
 
@@ -4887,9 +6040,9 @@ export async function startCameraScanner(video, options = {}) {
4887
6040
  // This is intentionally performed on a centered recovery crop.
4888
6041
  // A live preview can contain large dark borders, browser UI, a
4889
6042
  // monitor bezel or room background. Those pixels completely
4890
- // change global Auto Color/Otsu statistics even though the QR
4891
- // itself looks identical to a saved crop. Photoshop succeeded
4892
- // because the user's edited image was effectively QR-centric.
6043
+ // change global QuadQR Auto Color/Otsu statistics even though the QR
6044
+ // itself looks identical to a saved crop. QuadQR recovery stays
6045
+ // QR-centric so surrounding scene pixels do not dominate the correction.
4893
6046
  blackClip: options.cameraAutoColorBlackClip ?? 0.0001,
4894
6047
  whiteClip: options.cameraAutoColorWhiteClip ?? 0.004,
4895
6048
  highlightPercentile: options.cameraAutoColorHighlightPercentile ?? 0.95,
@@ -4935,7 +6088,7 @@ export async function startCameraScanner(video, options = {}) {
4935
6088
  state: "decoded",
4936
6089
  method: "camera-auto-color",
4937
6090
  elapsedMs: recoveryElapsedMs,
4938
- message: `Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} decoded v${recovered.version} · ECC ${recovered.eccLevel} · ${Math.round(recoveryElapsedMs)} ms`,
6091
+ message: `QuadQR Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} decoded v${recovered.version} · ECC ${recovered.eccLevel} · ${Math.round(recoveryElapsedMs)} ms`,
4939
6092
  ...(autoColorFrameDiagnostics ?? frameDiagnostics)
4940
6093
  });
4941
6094
  capturedFrame.enhancedImageData = correctedFrame;
@@ -4986,7 +6139,7 @@ export async function startCameraScanner(video, options = {}) {
4986
6139
  type: "method",
4987
6140
  state: "failed",
4988
6141
  method: profileName,
4989
- message: `Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} did not decode${autoColorFrameDiagnostics?.finderCount != null ? ` · ${autoColorFrameDiagnostics.finderCount} finder(s)` : ""}`,
6142
+ message: `QuadQR Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} did not decode${autoColorFrameDiagnostics?.finderCount != null ? ` · ${autoColorFrameDiagnostics.finderCount} finder(s)` : ""}`,
4990
6143
  ...(autoColorFrameDiagnostics ?? frameDiagnostics)
4991
6144
  });
4992
6145
  }
@@ -4996,7 +6149,7 @@ export async function startCameraScanner(video, options = {}) {
4996
6149
  type: "method",
4997
6150
  state: "failed",
4998
6151
  method: "camera-auto-color",
4999
- message: "All camera Auto Color profiles failed · continuing deeper recovery",
6152
+ message: "All camera QuadQR Auto Color profiles failed · continuing deeper recovery",
5000
6153
  ...frameDiagnostics
5001
6154
  });
5002
6155
  }
@@ -5065,8 +6218,8 @@ export async function startCameraScanner(video, options = {}) {
5065
6218
  emitDiagnostic({
5066
6219
  type: "success",
5067
6220
  state: "decoded",
5068
- method: "multi-frame-vote",
5069
- message: `Multi-frame recovery decoded v${combined.version} from ${combined.multiFrameCombined} frames`,
6221
+ method: "multi-frame-confidence-fusion",
6222
+ message: `Multi-frame confidence fusion decoded v${combined.version} from ${combined.multiFrameCombined} frames`,
5070
6223
  ...frameDiagnostics
5071
6224
  });
5072
6225
  missStreak = 0;
@@ -5078,13 +6231,570 @@ export async function startCameraScanner(video, options = {}) {
5078
6231
  busy = false;
5079
6232
  }
5080
6233
  }
5081
- timer = setTimeout(loop, scanInterval);
6234
+ scheduleNextScan();
5082
6235
  };
5083
6236
 
5084
- timer = setTimeout(loop, 0);
6237
+ scheduleNextScan();
5085
6238
  return { stream, stop, scanNow, video };
5086
6239
  }
5087
6240
 
6241
+
6242
+ function cameraWorkerSupported(options = {}) {
6243
+ if (options.cameraWorker === false) return false;
6244
+ return typeof Worker === "function" &&
6245
+ typeof createImageBitmap === "function" &&
6246
+ typeof OffscreenCanvas === "function";
6247
+ }
6248
+
6249
+ function serializableCameraWorkerOptions(options = {}) {
6250
+ const skip = new Set([
6251
+ "canvas",
6252
+ "constraints",
6253
+ "onDecode",
6254
+ "onDiagnostic",
6255
+ "onResult",
6256
+ "onScanMiss",
6257
+ "cameraWorkerUrl"
6258
+ ]);
6259
+ const out = {};
6260
+ for (const [key, value] of Object.entries(options)) {
6261
+ if (skip.has(key) || typeof value === "function" || value == null) continue;
6262
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
6263
+ out[key] = value;
6264
+ continue;
6265
+ }
6266
+ if (Array.isArray(value)) {
6267
+ out[key] = value.map((item) => {
6268
+ if (item && typeof item === "object") return { ...item };
6269
+ return item;
6270
+ });
6271
+ continue;
6272
+ }
6273
+ if (Object.getPrototypeOf(value) === Object.prototype) out[key] = { ...value };
6274
+ }
6275
+ return out;
6276
+ }
6277
+
6278
+ function resolveCameraWorkerUrl(options = {}) {
6279
+ if (options.cameraWorkerUrl) {
6280
+ return new URL(
6281
+ options.cameraWorkerUrl,
6282
+ typeof document !== "undefined" ? document.baseURI : import.meta.url
6283
+ );
6284
+ }
6285
+ return new URL("./camera-scanner-worker.js", import.meta.url);
6286
+ }
6287
+
6288
+ async function initializeCameraWorker(options = {}) {
6289
+ const worker = new Worker(resolveCameraWorkerUrl(options), {
6290
+ type: "module",
6291
+ name: "quadqr-camera-scanner"
6292
+ });
6293
+ let sequence = 0;
6294
+ const pending = new Map();
6295
+ let fatalError = null;
6296
+
6297
+ const rejectPending = (error) => {
6298
+ fatalError = error instanceof Error ? error : new Error(String(error));
6299
+ for (const { reject } of pending.values()) reject(fatalError);
6300
+ pending.clear();
6301
+ };
6302
+
6303
+ worker.addEventListener("message", (event) => {
6304
+ const message = event.data ?? {};
6305
+ const entry = pending.get(message.id);
6306
+ if (!entry) return;
6307
+ pending.delete(message.id);
6308
+ if (message.ok) entry.resolve(message.result);
6309
+ else {
6310
+ const error = new Error(message.error?.message ?? "QuadQR camera worker failed.");
6311
+ error.name = message.error?.name ?? "Error";
6312
+ error.stack = message.error?.stack ?? error.stack;
6313
+ error.debug = message.error?.debug ?? null;
6314
+ entry.reject(error);
6315
+ }
6316
+ });
6317
+ worker.addEventListener("error", (event) => {
6318
+ rejectPending(new Error(event?.message || "QuadQR camera worker crashed."));
6319
+ });
6320
+ worker.addEventListener("messageerror", () => {
6321
+ rejectPending(new Error("QuadQR camera worker returned an unreadable message."));
6322
+ });
6323
+
6324
+ const request = (type, payload = {}, transfer = []) => {
6325
+ if (fatalError) return Promise.reject(fatalError);
6326
+ const id = `qqr-camera-${++sequence}`;
6327
+ return new Promise((resolve, reject) => {
6328
+ pending.set(id, { resolve, reject });
6329
+ try {
6330
+ worker.postMessage({ id, type, ...payload }, transfer);
6331
+ } catch (error) {
6332
+ pending.delete(id);
6333
+ reject(error);
6334
+ }
6335
+ });
6336
+ };
6337
+
6338
+ try {
6339
+ const state = await request("init", { options: serializableCameraWorkerOptions(options) });
6340
+ if (!state?.offscreenCanvas) throw new Error("OffscreenCanvas is unavailable in the QuadQR camera worker.");
6341
+ return {
6342
+ worker,
6343
+ state,
6344
+ request,
6345
+ terminate() {
6346
+ rejectPending(new Error("QuadQR camera worker stopped."));
6347
+ worker.terminate();
6348
+ }
6349
+ };
6350
+ } catch (error) {
6351
+ worker.terminate();
6352
+ throw error;
6353
+ }
6354
+ }
6355
+
6356
+ async function captureCameraBitmap(video, source, maxDimension) {
6357
+ const cap = Math.max(1, Math.round(maxDimension));
6358
+ const videoWidth = Math.max(1, Math.round(video.videoWidth || source.width || 1));
6359
+ const videoHeight = Math.max(1, Math.round(video.videoHeight || source.height || 1));
6360
+ const sx = clampNumber(Math.round(source.x || 0), 0, Math.max(0, videoWidth - 1));
6361
+ const sy = clampNumber(Math.round(source.y || 0), 0, Math.max(0, videoHeight - 1));
6362
+ const sw = Math.max(1, Math.min(videoWidth - sx, Math.round(source.width || videoWidth)));
6363
+ const sh = Math.max(1, Math.min(videoHeight - sy, Math.round(source.height || videoHeight)));
6364
+ const scale = Math.min(1, cap / Math.max(sw, sh));
6365
+ const width = Math.max(1, Math.round(sw * scale));
6366
+ const height = Math.max(1, Math.round(sh * scale));
6367
+
6368
+ // Crop and resize before crossing the worker boundary. Previously every
6369
+ // 640px scan transferred a full 1080p camera bitmap and only then shrank it
6370
+ // inside OffscreenCanvas. On phones that creates unnecessary GPU/memory
6371
+ // pressure and can delay fresh-frame acquisition even though decoding is
6372
+ // off-thread. The fallback preserves compatibility with browsers that do not
6373
+ // implement the resize overload for video-backed ImageBitmap creation.
6374
+ try {
6375
+ const bitmap = await createImageBitmap(video, sx, sy, sw, sh, {
6376
+ resizeWidth: width,
6377
+ resizeHeight: height,
6378
+ resizeQuality: "medium"
6379
+ });
6380
+ return {
6381
+ bitmap,
6382
+ source: { x: 0, y: 0, width: bitmap.width, height: bitmap.height, cropped: Boolean(source.cropped) },
6383
+ originalSource: { ...source },
6384
+ preScaled: true
6385
+ };
6386
+ } catch {
6387
+ const bitmap = await createImageBitmap(video);
6388
+ return {
6389
+ bitmap,
6390
+ source: { ...source },
6391
+ originalSource: { ...source },
6392
+ preScaled: false
6393
+ };
6394
+ }
6395
+ }
6396
+
6397
+ function maximumFinderCount(workerResult) {
6398
+ let count = 0;
6399
+ for (const diagnostic of workerResult?.diagnostics ?? []) {
6400
+ count = Math.max(
6401
+ count,
6402
+ Number(diagnostic?.finderCount) || 0,
6403
+ Number(diagnostic?.bestPass?.finderCount) || 0
6404
+ );
6405
+ for (const pass of diagnostic?.finderPasses ?? []) {
6406
+ count = Math.max(count, Number(pass?.finderCount) || 0);
6407
+ }
6408
+ }
6409
+ return count;
6410
+ }
6411
+
6412
+ async function startCameraScannerWorker(video, options = {}) {
6413
+ assert(typeof navigator !== "undefined" && navigator.mediaDevices?.getUserMedia, "Camera API is unavailable.");
6414
+ assert(video, "A video element is required.");
6415
+
6416
+ // Keep fresh-frame scanning independent from the expensive damaged/color
6417
+ // recovery pipeline. Finder detection itself remains JavaScript; WASM is
6418
+ // only an optional grayscale/binary + CRC accelerator beneath both workers.
6419
+ // The fast worker can therefore inspect the newest frame even while the
6420
+ // recovery worker is spending hundreds of milliseconds on an older difficult
6421
+ // frame. No recovery method is removed.
6422
+ const fastWorkerOptions = {
6423
+ ...options,
6424
+ cameraPipelineMode: "fast",
6425
+ cameraHighResolutionRecovery: false,
6426
+ cameraAutoColorRecovery: false,
6427
+ autoEnhanceRecovery: false,
6428
+ fullFrameAutoEnhanceRecovery: false,
6429
+ multiFrame: false
6430
+ };
6431
+ const fastWorkerClient = await initializeCameraWorker(fastWorkerOptions);
6432
+
6433
+ let stream = null;
6434
+ try {
6435
+ stream = await navigator.mediaDevices.getUserMedia(
6436
+ options.constraints ?? {
6437
+ audio: false,
6438
+ video: {
6439
+ facingMode: { ideal: "environment" },
6440
+ width: { ideal: 1280 },
6441
+ height: { ideal: 720 },
6442
+ frameRate: { ideal: 30 }
6443
+ }
6444
+ }
6445
+ );
6446
+ await improveCameraTrack(stream);
6447
+
6448
+ video.srcObject = stream;
6449
+ video.setAttribute("playsinline", "");
6450
+ video.muted = true;
6451
+ await video.play();
6452
+ } catch (error) {
6453
+ fastWorkerClient.terminate();
6454
+ for (const track of stream?.getTracks?.() ?? []) track.stop();
6455
+ if (video.srcObject === stream) video.srcObject = null;
6456
+ throw error;
6457
+ }
6458
+
6459
+ const scanInterval = Math.max(24, Number(options.scanInterval ?? 33));
6460
+ const fastCaptureMaxDimension = Math.max(480, Math.round(options.maxDimension ?? 640));
6461
+ const recoveryCaptureMaxDimension = Math.max(
6462
+ fastCaptureMaxDimension,
6463
+ Math.round(options.cameraHighResolutionMaxDimension ?? 960)
6464
+ );
6465
+ const recoveryStrongFinderInterval = Math.max(80, Number(options.cameraRecoveryStrongFinderInterval ?? 120));
6466
+ const recoveryWeakFinderInterval = Math.max(recoveryStrongFinderInterval, Number(options.cameraRecoveryWeakFinderInterval ?? 260));
6467
+ const recoveryNoFinderInterval = Math.max(recoveryWeakFinderInterval, Number(options.cameraRecoveryNoFinderInterval ?? 850));
6468
+ const useVideoFrameCallback = options.useVideoFrameCallback !== false &&
6469
+ typeof video.requestVideoFrameCallback === "function";
6470
+
6471
+ let stopped = false;
6472
+ let busy = false;
6473
+ let recoveryBusy = false;
6474
+ let recoveryWorkerClient = null;
6475
+ let recoveryWorkerPromise = null;
6476
+ let recoveryWorkerFailed = false;
6477
+ let timer = null;
6478
+ let frameCallbackId = null;
6479
+ let lastScanStartedAt = -Infinity;
6480
+ let lastRecoveryStartedAt = -Infinity;
6481
+ let frameNumber = 0;
6482
+ let requestToken = 0;
6483
+ let recoveryToken = 0;
6484
+
6485
+ const diagnosticsEnabled = typeof options.onDiagnostic === "function";
6486
+ const emitDiagnostic = (event) => {
6487
+ if (!diagnosticsEnabled) return;
6488
+ try {
6489
+ options.onDiagnostic({
6490
+ timestamp: Date.now(),
6491
+ frame: event?.frame ?? frameNumber,
6492
+ cameraWorker: true,
6493
+ ...event
6494
+ });
6495
+ } catch {
6496
+ // Diagnostics are UI-only and must never interrupt scanning.
6497
+ }
6498
+ };
6499
+
6500
+ const ensureRecoveryWorker = async () => {
6501
+ if (recoveryWorkerClient) return recoveryWorkerClient;
6502
+ if (recoveryWorkerFailed) return null;
6503
+ if (!recoveryWorkerPromise) {
6504
+ recoveryWorkerPromise = initializeCameraWorker({ ...options, cameraPipelineMode: "full" })
6505
+ .then((client) => {
6506
+ if (stopped) {
6507
+ client.terminate();
6508
+ return null;
6509
+ }
6510
+ recoveryWorkerClient = client;
6511
+ return client;
6512
+ })
6513
+ .catch((error) => {
6514
+ recoveryWorkerFailed = true;
6515
+ emitDiagnostic({
6516
+ type: "recovery-worker-error",
6517
+ state: "fallback",
6518
+ method: "camera-fast-worker",
6519
+ message: `Parallel recovery worker unavailable · fast scanner remains active (${error?.message ?? String(error)})`
6520
+ });
6521
+ return null;
6522
+ })
6523
+ .finally(() => {
6524
+ recoveryWorkerPromise = null;
6525
+ });
6526
+ }
6527
+ return recoveryWorkerPromise;
6528
+ };
6529
+
6530
+ const track = stream.getVideoTracks?.()[0];
6531
+ const settings = track?.getSettings?.() ?? {};
6532
+ emitDiagnostic({
6533
+ type: "camera-ready",
6534
+ method: "camera-dual-worker",
6535
+ message: `Camera ready · ${settings.width ?? video.videoWidth}×${settings.height ?? video.videoHeight} · fast fresh-frame scanner + parallel recovery`,
6536
+ camera: {
6537
+ width: settings.width ?? video.videoWidth,
6538
+ height: settings.height ?? video.videoHeight,
6539
+ frameRate: settings.frameRate ?? null,
6540
+ facingMode: settings.facingMode ?? null
6541
+ },
6542
+ worker: fastWorkerClient.state,
6543
+ scanMaxDimension: fastCaptureMaxDimension,
6544
+ recoveryMaxDimension: recoveryCaptureMaxDimension
6545
+ });
6546
+
6547
+ const stop = () => {
6548
+ if (stopped) return;
6549
+ stopped = true;
6550
+ requestToken++;
6551
+ recoveryToken++;
6552
+ if (timer) clearTimeout(timer);
6553
+ if (frameCallbackId != null && typeof video.cancelVideoFrameCallback === "function") {
6554
+ try { video.cancelVideoFrameCallback(frameCallbackId); } catch {}
6555
+ }
6556
+ timer = null;
6557
+ frameCallbackId = null;
6558
+ fastWorkerClient.terminate();
6559
+ if (recoveryWorkerClient) {
6560
+ recoveryWorkerClient.terminate();
6561
+ recoveryWorkerClient = null;
6562
+ } else if (recoveryWorkerPromise) {
6563
+ void recoveryWorkerPromise.then((client) => client?.terminate()).catch(() => {});
6564
+ }
6565
+ for (const cameraTrack of stream.getTracks()) cameraTrack.stop();
6566
+ if (video.srcObject === stream) video.srcObject = null;
6567
+ };
6568
+
6569
+ // Preserve the historical immediate/manual helper as a synchronous scan of
6570
+ // the current video element. The continuous scanner itself stays off-thread.
6571
+ const scanNow = () => scanVideoFrame(video, {
6572
+ ...options,
6573
+ maxDimension: fastCaptureMaxDimension
6574
+ });
6575
+
6576
+ const emitResult = (result, frameMeta = null) => {
6577
+ if (stopped) return true;
6578
+ const normalizedMeta = frameMeta
6579
+ ? { frame: frameMeta.frame ?? frameNumber, ...frameMeta }
6580
+ : null;
6581
+ options.onResult?.(result, normalizedMeta);
6582
+ options.onDecode?.(result, normalizedMeta);
6583
+ if (options.stopOnResult ?? true) {
6584
+ stop();
6585
+ return true;
6586
+ }
6587
+ return false;
6588
+ };
6589
+
6590
+ const runRecovery = async (triggerFrame, finderCount) => {
6591
+ if (stopped || recoveryBusy) return;
6592
+ recoveryBusy = true;
6593
+ lastRecoveryStartedAt = nowMs();
6594
+ const token = ++recoveryToken;
6595
+ let bitmap = null;
6596
+ try {
6597
+ const client = await ensureRecoveryWorker();
6598
+ if (stopped || token !== recoveryToken) return;
6599
+ const recoveryClient = client ?? fastWorkerClient;
6600
+ const singleWorkerFallback = !client;
6601
+
6602
+ // Capture only after the recovery execution path is ready. This guarantees
6603
+ // the expensive path receives a fresh frame rather than a bitmap that sat
6604
+ // in memory while a recovery worker was starting.
6605
+ const visibleSource = visibleVideoSourceRect(video, options);
6606
+ const captured = await captureCameraBitmap(video, visibleSource, recoveryCaptureMaxDimension);
6607
+ bitmap = captured.bitmap;
6608
+ if (stopped || token !== recoveryToken) {
6609
+ bitmap.close?.();
6610
+ return;
6611
+ }
6612
+
6613
+ emitDiagnostic({
6614
+ type: "recovery-dispatch",
6615
+ state: "trying",
6616
+ method: "parallel-full-recovery",
6617
+ frame: triggerFrame,
6618
+ finderCount,
6619
+ message: finderCount > 0
6620
+ ? `Fast frame saw ${finderCount} finder${finderCount === 1 ? "" : "s"} · full recovery running in parallel`
6621
+ : "Periodic full recovery running in parallel while fast fresh-frame scanning continues"
6622
+ });
6623
+
6624
+ const recoveryPayload = { bitmap, source: captured.source, frame: triggerFrame };
6625
+ if (singleWorkerFallback) recoveryPayload.options = serializableCameraWorkerOptions(options);
6626
+ const workerResult = await recoveryClient.request(
6627
+ singleWorkerFallback ? "scan-full" : "scan",
6628
+ recoveryPayload,
6629
+ [bitmap]
6630
+ );
6631
+ bitmap = null;
6632
+ if (stopped || token !== recoveryToken) return;
6633
+
6634
+ for (const diagnostic of workerResult?.diagnostics ?? []) {
6635
+ emitDiagnostic({
6636
+ ...diagnostic,
6637
+ recoveryWorker: !singleWorkerFallback,
6638
+ singleWorkerRecoveryFallback: singleWorkerFallback,
6639
+ frame: triggerFrame
6640
+ });
6641
+ }
6642
+ if (workerResult?.ok) {
6643
+ emitResult(workerResult.result, { ...workerResult.frameMeta, frame: triggerFrame });
6644
+ }
6645
+ } catch (error) {
6646
+ try { bitmap?.close?.(); } catch {}
6647
+ if (!stopped) {
6648
+ emitDiagnostic({
6649
+ type: "recovery-worker-error",
6650
+ state: "error",
6651
+ method: "parallel-full-recovery",
6652
+ frame: triggerFrame,
6653
+ message: error?.message ?? String(error)
6654
+ });
6655
+ }
6656
+ } finally {
6657
+ recoveryBusy = false;
6658
+ }
6659
+ };
6660
+
6661
+ const maybeDispatchRecovery = (workerResult, triggerFrame) => {
6662
+ if (stopped || recoveryBusy) return;
6663
+ const finderCount = maximumFinderCount(workerResult);
6664
+ const elapsed = nowMs() - lastRecoveryStartedAt;
6665
+ const minimumInterval = finderCount >= 2
6666
+ ? recoveryStrongFinderInterval
6667
+ : finderCount === 1
6668
+ ? recoveryWeakFinderInterval
6669
+ : recoveryNoFinderInterval;
6670
+ if (elapsed >= minimumInterval) void runRecovery(triggerFrame, finderCount);
6671
+ };
6672
+
6673
+ const scheduleNextScan = () => {
6674
+ if (stopped) return;
6675
+ const runWhenDue = () => {
6676
+ frameCallbackId = null;
6677
+ if (stopped) return;
6678
+ const remaining = scanInterval - (nowMs() - lastScanStartedAt);
6679
+ if (remaining > 1) {
6680
+ timer = setTimeout(() => {
6681
+ timer = null;
6682
+ scheduleNextScan();
6683
+ }, remaining);
6684
+ return;
6685
+ }
6686
+ void loop();
6687
+ };
6688
+
6689
+ if (useVideoFrameCallback) {
6690
+ frameCallbackId = video.requestVideoFrameCallback(runWhenDue);
6691
+ } else {
6692
+ const remaining = Math.max(0, scanInterval - (nowMs() - lastScanStartedAt));
6693
+ timer = setTimeout(() => {
6694
+ timer = null;
6695
+ runWhenDue();
6696
+ }, remaining);
6697
+ }
6698
+ };
6699
+
6700
+ const loop = async () => {
6701
+ if (stopped) return;
6702
+ if (!busy && video.readyState >= 2) {
6703
+ busy = true;
6704
+ frameNumber++;
6705
+ const currentFrame = frameNumber;
6706
+ lastScanStartedAt = nowMs();
6707
+ const token = ++requestToken;
6708
+ let bitmap = null;
6709
+ try {
6710
+ const visibleSource = visibleVideoSourceRect(video, options);
6711
+ const captured = await captureCameraBitmap(video, visibleSource, fastCaptureMaxDimension);
6712
+ bitmap = captured.bitmap;
6713
+ if (stopped || token !== requestToken) {
6714
+ bitmap.close?.();
6715
+ return;
6716
+ }
6717
+ const workerResult = await fastWorkerClient.request(
6718
+ "scan",
6719
+ { bitmap, source: captured.source, frame: currentFrame },
6720
+ [bitmap]
6721
+ );
6722
+ bitmap = null;
6723
+ if (stopped || token !== requestToken) return;
6724
+
6725
+ for (const diagnostic of workerResult?.diagnostics ?? []) {
6726
+ emitDiagnostic({ ...diagnostic, fastWorker: true, frame: currentFrame });
6727
+ }
6728
+ if (workerResult?.ok) {
6729
+ if (emitResult(workerResult.result, { ...workerResult.frameMeta, frame: currentFrame })) return;
6730
+ } else {
6731
+ // Full perspective/color/damage recovery runs independently. Do not
6732
+ // await it here: the next camera callback must remain free to inspect
6733
+ // a newer frame immediately.
6734
+ maybeDispatchRecovery(workerResult, currentFrame);
6735
+ const error = new Error(workerResult?.error?.message ?? "Unable to decode QuadQR frame.");
6736
+ error.name = workerResult?.error?.name ?? "Error";
6737
+ error.debug = workerResult?.error?.debug ?? null;
6738
+ options.onScanMiss?.(error);
6739
+ }
6740
+ } catch (error) {
6741
+ try { bitmap?.close?.(); } catch {}
6742
+ if (!stopped) {
6743
+ emitDiagnostic({
6744
+ type: "worker-error",
6745
+ state: "error",
6746
+ method: "camera-fast-worker",
6747
+ frame: currentFrame,
6748
+ message: error?.message ?? String(error)
6749
+ });
6750
+ options.onScanMiss?.(error);
6751
+ }
6752
+ } finally {
6753
+ busy = false;
6754
+ }
6755
+ }
6756
+ scheduleNextScan();
6757
+ };
6758
+
6759
+ scheduleNextScan();
6760
+ return {
6761
+ stream,
6762
+ stop,
6763
+ scanNow,
6764
+ video,
6765
+ worker: true,
6766
+ workerMode: "dual-pipeline",
6767
+ workerState: fastWorkerClient.state
6768
+ };
6769
+ }
6770
+
6771
+ /**
6772
+ * Start continuous camera scanning. Modern browsers use a dedicated module
6773
+ * worker by default so the complete recovery pipeline can remain enabled
6774
+ * without blocking rendering/input. Unsupported/CSP-restricted environments
6775
+ * automatically fall back to the original main-thread scanner.
6776
+ */
6777
+ export async function startCameraScanner(video, options = {}) {
6778
+ if (cameraWorkerSupported(options)) {
6779
+ try {
6780
+ return await startCameraScannerWorker(video, options);
6781
+ } catch (error) {
6782
+ if (options.cameraWorkerRequired) throw error;
6783
+ try {
6784
+ options.onDiagnostic?.({
6785
+ timestamp: Date.now(),
6786
+ frame: 0,
6787
+ type: "worker-fallback",
6788
+ state: "fallback",
6789
+ method: "camera-main-thread",
6790
+ message: `Background scanner unavailable · using main-thread fallback (${error?.message ?? String(error)})`
6791
+ });
6792
+ } catch {}
6793
+ }
6794
+ }
6795
+ return startCameraScannerMainThread(video, options);
6796
+ }
6797
+
5088
6798
  export function rectifyDetectedCode(imageData, options = {}) {
5089
6799
  const candidates = detectCodeGeometry(imageData, {
5090
6800
  minVersion: options.minVersion ?? MIN_VERSION,
@@ -5129,6 +6839,9 @@ export const internals = Object.freeze({
5129
6839
  restoreLogicalOrder,
5130
6840
  cellsToSymbolConfidences,
5131
6841
  decodeRsAdaptive,
6842
+ classifierFromPaletteRgb,
6843
+ classifyRgb,
6844
+ observationDataAgreement,
5132
6845
  encodeProtectedHeader,
5133
6846
  decodeProtectedHeader,
5134
6847
  selectBestFrameObservation,