quadqr-js 1.4.2 → 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";
@@ -71,7 +73,8 @@ import {
71
73
  BROTLI_QUALITY_MAX
72
74
  } from "./brotli.js";
73
75
 
74
- export const FORMAT_VERSION = 5;
76
+ export const FORMAT_VERSION = 6;
77
+ export const LEGACY_FORMAT_VERSION = 5;
75
78
  export const MIN_VERSION = 1;
76
79
  export const MAX_VERSION = 40;
77
80
  export const DEFAULT_ECC_LEVEL = "M";
@@ -1133,7 +1136,7 @@ function getEffectiveEcc(version, eccLevel) {
1133
1136
  return version === COMPACT_VERSION ? COMPACT_ECC_LEVELS[normalized] : ECC_LEVELS[normalized];
1134
1137
  }
1135
1138
 
1136
- function makeHeader(payloadLength, flags, version) {
1139
+ function makeHeader(payloadLength, flags, version, formatVersion = FORMAT_VERSION) {
1137
1140
  assert(payloadLength >= 0 && payloadLength <= 0xffffffff, "Payload is too large.");
1138
1141
 
1139
1142
  if (version === COMPACT_VERSION) {
@@ -1148,7 +1151,7 @@ function makeHeader(payloadLength, flags, version) {
1148
1151
 
1149
1152
  const header = new Uint8Array(HEADER_BYTES);
1150
1153
  header.set(MAGIC, 0);
1151
- header[4] = FORMAT_VERSION;
1154
+ header[4] = formatVersion;
1152
1155
  header[5] = flags & 0xff;
1153
1156
  header.set(u32be(payloadLength), 6);
1154
1157
  return header;
@@ -1170,14 +1173,22 @@ function parseHeader(header, version) {
1170
1173
  return { flags: header[1], payloadLength: header[2] };
1171
1174
  }
1172
1175
 
1173
- if (!magicMatches(header) || header[4] !== FORMAT_VERSION) {
1176
+ const formatVersion = header[4];
1177
+ if (!magicMatches(header) || (formatVersion !== FORMAT_VERSION && formatVersion !== LEGACY_FORMAT_VERSION)) {
1174
1178
  throw new Error("QuadQR magic/version mismatch.");
1175
1179
  }
1176
- return { flags: header[5], payloadLength: readU32be(header, 6) };
1180
+ return { formatVersion, flags: header[5], payloadLength: readU32be(header, 6) };
1177
1181
  }
1178
1182
 
1179
- function createLayout(version) {
1183
+ function alignmentProfileForFormat(formatVersion = FORMAT_VERSION) {
1184
+ return formatVersion === LEGACY_FORMAT_VERSION
1185
+ ? ALIGNMENT_PROFILE_LEGACY_3
1186
+ : ALIGNMENT_PROFILE_STANDARD_5;
1187
+ }
1188
+
1189
+ function createLayout(version, options = {}) {
1180
1190
  validateVersion(version);
1191
+ const alignmentProfile = options.alignmentProfile ?? alignmentProfileForFormat(options.formatVersion ?? FORMAT_VERSION);
1181
1192
  const size = sizeForVersion(version);
1182
1193
  const matrix = make2D(size, CELL.WHITE);
1183
1194
  const reserved = make2D(size, false);
@@ -1305,7 +1316,7 @@ function createLayout(version) {
1305
1316
  reserveAndSet(row, 6, row % 2 === 0 ? CELL.BLACK : CELL.WHITE);
1306
1317
  }
1307
1318
 
1308
- const alignments = alignmentPatternCentersForVersion(version).map(drawAlignmentPattern);
1319
+ const alignments = alignmentPatternCentersForVersion(version, { profile: alignmentProfile }).map(drawAlignmentPattern);
1309
1320
  const alignment = alignments[alignments.length - 1];
1310
1321
  const calibrationStrip = drawCalibrationStrip();
1311
1322
 
@@ -1329,6 +1340,7 @@ function createLayout(version) {
1329
1340
  return {
1330
1341
  version,
1331
1342
  size,
1343
+ alignmentProfile,
1332
1344
  matrix,
1333
1345
  reserved,
1334
1346
  dataPositions,
@@ -1977,8 +1989,8 @@ function alignmentPatternMismatchRatio(matrix, pattern) {
1977
1989
  return mismatches / total;
1978
1990
  }
1979
1991
 
1980
- function alignmentGridMismatchRatio(matrix, version) {
1981
- const patterns = alignmentPatternCentersForVersion(version);
1992
+ function alignmentGridMismatchRatio(matrix, version, alignmentProfile = ALIGNMENT_PROFILE_STANDARD_5) {
1993
+ const patterns = alignmentPatternCentersForVersion(version, { profile: alignmentProfile });
1982
1994
  let weightedMismatch = 0;
1983
1995
  let totalWeight = 0;
1984
1996
  for (const pattern of patterns) {
@@ -1990,7 +2002,7 @@ function alignmentGridMismatchRatio(matrix, version) {
1990
2002
  return totalWeight ? weightedMismatch / totalWeight : 0;
1991
2003
  }
1992
2004
 
1993
- function validateStructure(matrix, tolerance = 0) {
2005
+ function validateStructure(matrix, tolerance = 0, alignmentProfile = ALIGNMENT_PROFILE_STANDARD_5) {
1994
2006
  const size = matrix.length;
1995
2007
  if (size < 21 || matrix.some((row) => row.length !== size)) return false;
1996
2008
  const version = versionFromSize(size);
@@ -2003,11 +2015,11 @@ function validateStructure(matrix, tolerance = 0) {
2003
2015
  if (finderRatios.some((ratio) => ratio > tolerance)) return false;
2004
2016
 
2005
2017
  const alignmentTolerance = Math.max(tolerance, 0.12);
2006
- const primary = alignmentPatternCentersForVersion(version).at(-1);
2018
+ const primary = alignmentPatternCentersForVersion(version, { profile: alignmentProfile }).at(-1);
2007
2019
  if (primary && alignmentPatternMismatchRatio(matrix, primary) > alignmentTolerance) {
2008
2020
  return false;
2009
2021
  }
2010
- if (alignmentGridMismatchRatio(matrix, version) > alignmentTolerance) return false;
2022
+ if (alignmentGridMismatchRatio(matrix, version, alignmentProfile) > alignmentTolerance) return false;
2011
2023
  return true;
2012
2024
  }
2013
2025
 
@@ -2195,13 +2207,13 @@ function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConf
2195
2207
  };
2196
2208
  }
2197
2209
 
2198
- function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = null, options = {}) {
2210
+ function decodeCanonicalWithProfile(matrix, rotation, tolerance = 0, confidenceMatrix = null, options = {}, alignmentProfile = ALIGNMENT_PROFILE_STANDARD_5) {
2199
2211
  const size = matrix.length;
2200
2212
  const version = versionFromSize(size);
2201
2213
  if (!version) throw new Error(`Unsupported matrix size ${size}.`);
2202
- 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.");
2203
2215
 
2204
- const layout = createLayout(version);
2216
+ const layout = createLayout(version, { alignmentProfile });
2205
2217
  const errors = [];
2206
2218
  const hintedEncoding = options.cellEncodingHint != null
2207
2219
  ? normalizeCellEncoding(options.cellEncodingHint)
@@ -2251,6 +2263,10 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
2251
2263
  );
2252
2264
  const header = headerDecoded.header;
2253
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
+ }
2254
2270
  const declaredCellEncoding = cellEncodingFromFlags(flags);
2255
2271
  if (declaredCellEncoding !== cellEncoding) {
2256
2272
  throw new Error(`Header declares ${declaredCellEncoding}, not ${cellEncoding}.`);
@@ -2288,10 +2304,11 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
2288
2304
  return {
2289
2305
  ok: true,
2290
2306
  format: "QuadQR",
2291
- formatVersion: FORMAT_VERSION,
2307
+ formatVersion: decodedFormatVersion,
2292
2308
  version,
2293
2309
  size,
2294
2310
  alignmentPatterns: layout.alignments.length,
2311
+ alignmentProfile,
2295
2312
  maskId,
2296
2313
  rotation,
2297
2314
  flags,
@@ -2339,17 +2356,42 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
2339
2356
  throw new Error(`QuadQR decode failed. ${errors.join(" | ")}`);
2340
2357
  }
2341
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
+
2342
2375
  function trySoftMatrixDecode(matrix, alternatives, confidenceMatrix, degrees, tolerance, options = {}) {
2343
2376
  if (!alternatives || !confidenceMatrix || options.softDecoding === false) return null;
2344
2377
  const version = versionFromSize(matrix.length);
2345
2378
  if (!version) return null;
2346
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
+ }
2347
2389
  const threshold = clampNumber(options.softDecodeConfidence ?? 0.72, 0.05, 0.95);
2348
2390
  const maxCells = Math.max(2, Math.min(16, Math.round(options.softDecodeMaxCells ?? 10)));
2349
2391
  const pairCells = Math.max(2, Math.min(maxCells, Math.round(options.softDecodePairCells ?? 6)));
2350
2392
  const ranked = [];
2351
2393
 
2352
- for (const [row, col] of layout.dataPositions) {
2394
+ for (const [row, col] of softPositions) {
2353
2395
  const alternative = alternatives[row]?.[col];
2354
2396
  const confidence = confidenceMatrix[row]?.[col] ?? 1;
2355
2397
  if (!Number.isInteger(alternative) || alternative === matrix[row][col] || confidence > threshold) continue;
@@ -3702,27 +3744,31 @@ function paletteClassifierAttempts(observedPalette) {
3702
3744
  }
3703
3745
 
3704
3746
  function tryPerspectiveScan(imageData, options) {
3705
- const geometryCandidates = detectCodeGeometry(imageData, {
3706
- minVersion: options.minVersion ?? MIN_VERSION,
3707
- maxVersion: options.maxVersion ?? MAX_VERSION,
3708
- maxCandidates: options.maxGeometryCandidates ?? 8,
3709
- finderRecovery: options.finderRecovery,
3710
- finderAutoColorBlackClip: options.finderAutoColorBlackClip,
3711
- finderAutoColorWhiteClip: options.finderAutoColorWhiteClip,
3712
- finderAutoColorHighlightPercentile: options.finderAutoColorHighlightPercentile,
3713
- finderAutoColorOutputHighlight: options.finderAutoColorOutputHighlight,
3714
- finderAutoColorAnalysisInset: options.finderAutoColorAnalysisInset,
3715
- finderAutoColorMinimumInputRange: options.finderAutoColorMinimumInputRange,
3716
- finderAutoColorTargetSamples: options.finderAutoColorTargetSamples,
3717
- preciseAlignment: options.preciseAlignment,
3718
- diagnostics: options._visionDiagnostics,
3719
- diagnosticLabel: options._diagnosticLabel ?? "normal"
3720
- });
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
+ });
3721
3765
  if (Array.isArray(options._geometryCollector)) options._geometryCollector.push(...geometryCandidates);
3722
3766
  const results = [];
3723
3767
 
3724
3768
  for (const geometry of geometryCandidates) {
3725
- const layout = createLayout(geometry.version);
3769
+ const layout = createLayout(geometry.version, {
3770
+ alignmentProfile: geometry.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5
3771
+ });
3726
3772
  const sampleProfiles = [{
3727
3773
  sampleMode: options.sampleMode ?? "cross",
3728
3774
  sampleRadius: options.sampleRadius ?? 0.16,
@@ -3759,7 +3805,7 @@ function tryPerspectiveScan(imageData, options) {
3759
3805
  continue;
3760
3806
  }
3761
3807
 
3762
- const tryAttempt = (attempt, rgbGrid, metadata = {}) => {
3808
+ const tryAttempt = (attempt, rgbGrid, metadata = {}, allowSoftDecoding = options.softDecoding !== false) => {
3763
3809
  const activeObservedPalette = metadata.observedPalette ?? observedPalette;
3764
3810
  const activeSamplingMode = metadata.samplingMode ?? profile.sampleMode;
3765
3811
  const classified = classifySampledRgbGrid(rgbGrid, attempt.classifier, layout);
@@ -3784,6 +3830,9 @@ function tryPerspectiveScan(imageData, options) {
3784
3830
  structureTolerance: options.structureTolerance ?? 0.18,
3785
3831
  cellConfidence: classified.confidence,
3786
3832
  cellAlternatives: classified.alternatives,
3833
+ cellEncodingHint: CELL_ENCODINGS.RGBW,
3834
+ alignmentProfileHint: geometry.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5,
3835
+ softDecoding: allowSoftDecoding,
3787
3836
  maxErasureConfidence: options.maxErasureConfidence
3788
3837
  });
3789
3838
  if (decoded.version !== geometry.version) return false;
@@ -3812,7 +3861,7 @@ function tryPerspectiveScan(imageData, options) {
3812
3861
  }
3813
3862
  };
3814
3863
 
3815
- const tryTriangleAttempt = (attempt, metadata = {}) => {
3864
+ const tryTriangleAttempt = (attempt, metadata = {}, allowSoftDecoding = options.softDecoding !== false) => {
3816
3865
  if (!triangleSampled?.triangleGrid) return false;
3817
3866
  const activeObservedPalette = metadata.observedPalette ?? observedPalette;
3818
3867
  const activeSamplingMode = metadata.samplingMode ?? `${profile.sampleMode}-triangle16`;
@@ -3845,6 +3894,8 @@ function tryPerspectiveScan(imageData, options) {
3845
3894
  cellConfidence: classified.confidence,
3846
3895
  cellAlternatives: classified.alternatives,
3847
3896
  cellEncodingHint: CELL_ENCODINGS.TRIANGLE16,
3897
+ alignmentProfileHint: geometry.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5,
3898
+ softDecoding: allowSoftDecoding,
3848
3899
  maxErasureConfidence: options.maxErasureConfidence
3849
3900
  });
3850
3901
  if (decoded.version !== geometry.version) return false;
@@ -3873,21 +3924,38 @@ function tryPerspectiveScan(imageData, options) {
3873
3924
  // Fast path: preserve the original observed-RGB classifier first, then
3874
3925
  // try per-channel white balancing. Most clean frames stop here without
3875
3926
  // paying for the more expensive spatial normalization fallback.
3876
- for (const attempt of paletteClassifierAttempts(observedPalette)) {
3877
- 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)) {
3878
3930
  geometryDecoded = true;
3879
3931
  break;
3880
3932
  }
3881
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
+ }
3882
3946
 
3883
3947
  if (!geometryDecoded && options.spatialColorNormalization !== false) {
3884
3948
  try {
3885
3949
  const normalizedGrid = spatiallyNormalizeRgbGrid(sampled.rgbGrid, layout.calibration);
3886
3950
  const normalizedPalette = sampleObservedPalette(normalizedGrid, layout.calibration, { robust: true });
3887
- geometryDecoded = tryAttempt({
3951
+ const spatialAttempt = {
3888
3952
  classifier: classifierFromPaletteRgb(normalizedPalette, "raw"),
3889
3953
  colorNormalization: "spatial-white-balanced"
3890
- }, normalizedGrid);
3954
+ };
3955
+ geometryDecoded = tryAttempt(spatialAttempt, normalizedGrid, {}, false);
3956
+ if (!geometryDecoded && options.softDecoding !== false) {
3957
+ geometryDecoded = tryAttempt(spatialAttempt, normalizedGrid, {}, true);
3958
+ }
3891
3959
  } catch {
3892
3960
  // Continue to the recovery profile below.
3893
3961
  }
@@ -3904,20 +3972,32 @@ function tryPerspectiveScan(imageData, options) {
3904
3972
  saturation: options.autoEnhanceSaturation
3905
3973
  });
3906
3974
  const enhancedPalette = sampleObservedPalette(enhancedGrid, layout.calibration, { robust: true });
3907
- for (const attempt of paletteClassifierAttempts(enhancedPalette)) {
3908
- const recoveredAttempt = {
3909
- ...attempt,
3910
- colorNormalization: `auto-tone-contrast-color/${attempt.colorNormalization}`
3911
- };
3975
+ const enhancedAttempts = paletteClassifierAttempts(enhancedPalette).map((attempt) => ({
3976
+ ...attempt,
3977
+ colorNormalization: `auto-tone-contrast-color/${attempt.colorNormalization}`
3978
+ }));
3979
+ for (const recoveredAttempt of enhancedAttempts) {
3912
3980
  if (tryAttempt(recoveredAttempt, enhancedGrid, {
3913
3981
  observedPalette: enhancedPalette,
3914
3982
  autoEnhanced: true,
3915
3983
  recoveryMode: "module-grid-auto-tone-contrast-color"
3916
- })) {
3984
+ }, false)) {
3917
3985
  geometryDecoded = true;
3918
3986
  break;
3919
3987
  }
3920
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
+ }
3921
4001
  } catch {
3922
4002
  // Continue to the QR-region pixel recovery below.
3923
4003
  }
@@ -3962,21 +4042,34 @@ function tryPerspectiveScan(imageData, options) {
3962
4042
  layout.calibration,
3963
4043
  { robust: true }
3964
4044
  );
3965
- for (const attempt of paletteClassifierAttempts(enhancedPalette)) {
3966
- const recoveredAttempt = {
3967
- ...attempt,
3968
- colorNormalization: `rectified-auto-tone-contrast-color/${attempt.colorNormalization}`
3969
- };
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) {
3970
4050
  if (tryAttempt(recoveredAttempt, enhancedSampled.rgbGrid, {
3971
4051
  observedPalette: enhancedPalette,
3972
4052
  samplingMode: "rectified-auto-enhance",
3973
4053
  autoEnhanced: true,
3974
4054
  recoveryMode: "rectified-auto-tone-contrast-color"
3975
- })) {
4055
+ }, false)) {
3976
4056
  geometryDecoded = true;
3977
4057
  break;
3978
4058
  }
3979
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
+ }
3980
4073
  } catch {
3981
4074
  // Geometry refinement remains available as the final bounded fallback.
3982
4075
  }
@@ -3985,6 +4078,15 @@ function tryPerspectiveScan(imageData, options) {
3985
4078
  if (geometryDecoded) break;
3986
4079
  }
3987
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
+
3988
4090
  // Slow-path geometry micro-refinement. Finder/alignment detection can be
3989
4091
  // correct while a soft-focus or smeared camera shifts the effective module
3990
4092
  // centres by a small fraction of a cell. Clean scans never reach this code.
@@ -4131,6 +4233,7 @@ function tryPerspectiveScan(imageData, options) {
4131
4233
  cellConfidence: candidate.classified.confidence,
4132
4234
  cellAlternatives: candidate.classified.alternatives,
4133
4235
  cellEncodingHint: candidate.cellEncoding,
4236
+ alignmentProfileHint: geometry.alignmentProfile ?? ALIGNMENT_PROFILE_STANDARD_5,
4134
4237
  maxErasureConfidence: options.maxErasureConfidence
4135
4238
  });
4136
4239
  if (decoded.version !== geometry.version) continue;
@@ -4159,6 +4262,11 @@ function tryPerspectiveScan(imageData, options) {
4159
4262
  }
4160
4263
  }
4161
4264
  }
4265
+
4266
+
4267
+ if (geometryDecoded && options.collectAllGeometryResults !== true) {
4268
+ return results[results.length - 1] ?? null;
4269
+ }
4162
4270
  }
4163
4271
 
4164
4272
  results.sort((a, b) =>
@@ -4981,6 +5089,38 @@ export function scanImageData(imageData, options = {}) {
4981
5089
  };
4982
5090
 
4983
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
+
4984
5124
  const perspective = tryPerspectiveScan(imageData, scanOptions);
4985
5125
  if (perspective) return decorateScanResult(perspective, debugContext, options);
4986
5126
  }
@@ -5306,6 +5446,21 @@ export function scanVideoFrame(video, options = {}) {
5306
5446
  _visionDiagnostics: visionDiagnostics
5307
5447
  });
5308
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
+ }
5309
5464
  if (!source.cropped) return result;
5310
5465
  return {
5311
5466
  ...result,
@@ -5458,7 +5613,7 @@ async function improveCameraTrack(stream) {
5458
5613
  }
5459
5614
  }
5460
5615
 
5461
- export async function startCameraScanner(video, options = {}) {
5616
+ async function startCameraScannerMainThread(video, options = {}) {
5462
5617
  assert(typeof navigator !== "undefined" && navigator.mediaDevices?.getUserMedia, "Camera API is unavailable.");
5463
5618
  assert(video, "A video element is required.");
5464
5619
 
@@ -5467,8 +5622,8 @@ export async function startCameraScanner(video, options = {}) {
5467
5622
  audio: false,
5468
5623
  video: {
5469
5624
  facingMode: { ideal: "environment" },
5470
- width: { ideal: 1920 },
5471
- height: { ideal: 1080 },
5625
+ width: { ideal: 1280 },
5626
+ height: { ideal: 720 },
5472
5627
  frameRate: { ideal: 30 }
5473
5628
  }
5474
5629
  }
@@ -5480,7 +5635,11 @@ export async function startCameraScanner(video, options = {}) {
5480
5635
  video.muted = true;
5481
5636
  await video.play();
5482
5637
 
5483
- 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));
5484
5643
  const scratchCanvas = document.createElement("canvas");
5485
5644
  const highResolutionCanvas = document.createElement("canvas");
5486
5645
  const multiFrameEnabled = options.multiFrame !== false;
@@ -5491,16 +5650,23 @@ export async function startCameraScanner(video, options = {}) {
5491
5650
  const cameraAutoEnhanceEvery = Math.max(1, Math.round(options.cameraAutoEnhanceEvery ?? 2));
5492
5651
  const cameraFinderRecoveryEvery = Math.max(1, Math.round(options.cameraFinderRecoveryEvery ?? 2));
5493
5652
  const cameraHighResolutionEvery = Math.max(1, Math.round(options.cameraHighResolutionEvery ?? 2));
5494
- const baseCameraMaxDimension = Math.max(480, Math.round(options.maxDimension ?? 1080));
5653
+ const baseCameraMaxDimension = Math.max(480, Math.round(options.maxDimension ?? 640));
5495
5654
  const cameraHighResolutionMaxDimension = Math.max(
5496
5655
  baseCameraMaxDimension,
5497
- Math.round(options.cameraHighResolutionMaxDimension ?? 1600)
5656
+ Math.round(options.cameraHighResolutionMaxDimension ?? 960)
5498
5657
  );
5499
5658
  let missStreak = 0;
5500
5659
  let stopped = false;
5501
5660
  let busy = false;
5502
5661
  let timer = null;
5662
+ let frameCallbackId = null;
5663
+ let lastScanStartedAt = -Infinity;
5503
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";
5504
5670
 
5505
5671
  const diagnosticsEnabled = typeof options.onDiagnostic === "function";
5506
5672
  const emitDiagnostic = (event) => {
@@ -5536,6 +5702,12 @@ export async function startCameraScanner(video, options = {}) {
5536
5702
  const stop = () => {
5537
5703
  stopped = true;
5538
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;
5539
5711
  observationHistory.clear();
5540
5712
  for (const track of stream.getTracks()) track.stop();
5541
5713
  if (video.srcObject === stream) video.srcObject = null;
@@ -5544,9 +5716,30 @@ export async function startCameraScanner(video, options = {}) {
5544
5716
  const scanNow = () => scanVideoFrame(video, {
5545
5717
  ...options,
5546
5718
  maxDimension: baseCameraMaxDimension,
5547
- canvas: scratchCanvas
5719
+ canvas: scratchCanvas,
5720
+ _geometryHints: options.cameraGeometryReuse === false || !cameraGeometryHint ? undefined : [cameraGeometryHint]
5548
5721
  });
5549
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
+
5550
5743
  const emitResult = (result, capturedFrame = null, diagnostic = null) => {
5551
5744
  const frameMeta = capturedFrame?.imageData ? {
5552
5745
  frame: frameNumber,
@@ -5624,6 +5817,33 @@ export async function startCameraScanner(video, options = {}) {
5624
5817
  }
5625
5818
  };
5626
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
+
5627
5847
  const loop = async () => {
5628
5848
  if (stopped) return;
5629
5849
  if (!busy && video.readyState >= 2) {
@@ -5633,6 +5853,7 @@ export async function startCameraScanner(video, options = {}) {
5633
5853
  const frameDiagnostics = {};
5634
5854
  const capturedFrame = {};
5635
5855
  const frameStarted = nowMs();
5856
+ lastScanStartedAt = frameStarted;
5636
5857
  let allowAutoEnhance = false;
5637
5858
  let allowFinderRecovery = false;
5638
5859
  try {
@@ -5660,11 +5881,13 @@ export async function startCameraScanner(video, options = {}) {
5660
5881
  fullFrameAutoEnhanceRecovery: options.fullFrameAutoEnhanceRecovery ?? false,
5661
5882
  maxDimension: baseCameraMaxDimension,
5662
5883
  canvas: scratchCanvas,
5884
+ _geometryHints: options.cameraGeometryReuse === false || !cameraGeometryHint ? undefined : [cameraGeometryHint],
5663
5885
  _capturedFrame: capturedFrame,
5664
5886
  _observationCollector: observations,
5665
5887
  _frameDiagnostics: frameDiagnostics
5666
5888
  });
5667
5889
  const elapsedMs = nowMs() - frameStarted;
5890
+ updateCameraGeometryHint(frameDiagnostics, result);
5668
5891
  emitDiagnostic({
5669
5892
  type: "frame",
5670
5893
  state: "decoded",
@@ -5686,6 +5909,7 @@ export async function startCameraScanner(video, options = {}) {
5686
5909
  if (emitResult(result, capturedFrame, frameDiagnostics)) return;
5687
5910
  } catch (error) {
5688
5911
  missStreak++;
5912
+ updateCameraGeometryHint(frameDiagnostics);
5689
5913
  const fastElapsedMs = nowMs() - frameStarted;
5690
5914
  emitDiagnostic({
5691
5915
  type: "frame",
@@ -5701,7 +5925,7 @@ export async function startCameraScanner(video, options = {}) {
5701
5925
 
5702
5926
  // Geometry-aware high-resolution retry. A dense QuadQR can look large
5703
5927
  // enough in the preview while each individual module has become too
5704
- // 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
5705
5929
  // sees at least two convincing finders, spend one bounded retry on a
5706
5930
  // higher-resolution copy of the visible camera ROI. Empty frames and
5707
5931
  // ordinary small codes never pay this cost.
@@ -6007,13 +6231,570 @@ export async function startCameraScanner(video, options = {}) {
6007
6231
  busy = false;
6008
6232
  }
6009
6233
  }
6010
- timer = setTimeout(loop, scanInterval);
6234
+ scheduleNextScan();
6011
6235
  };
6012
6236
 
6013
- timer = setTimeout(loop, 0);
6237
+ scheduleNextScan();
6014
6238
  return { stream, stop, scanNow, video };
6015
6239
  }
6016
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
+
6017
6798
  export function rectifyDetectedCode(imageData, options = {}) {
6018
6799
  const candidates = detectCodeGeometry(imageData, {
6019
6800
  minVersion: options.minVersion ?? MIN_VERSION,