quadqr-js 1.5.2 → 1.5.4

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.
package/README.md CHANGED
@@ -258,7 +258,7 @@ Compression modes are `none`, `auto`, `smart`, `brotli`, `deflate`, and `lz`. `a
258
258
 
259
259
  Signing can also be composed with Secure Payload. QuadQR compresses if requested, signs the normal payload with the private key, then encrypts the protected bytes with AES-256-GCM. A verifier supplies the trusted public key separately, or resolves it from `keyId`.
260
260
 
261
- The renderer supports an explicit `mode: "print"`. Print mode enforces a minimum 4-module quiet zone, uses darker print-safe RGB defaults, and prefers Classic solid modules. `getPrintGuidance()` converts a chosen physical size into module millimeters/pixels so print layouts can be checked before production testing.
261
+ The renderer supports an explicit `mode: "print"`. Print mode uses darker print-safe RGB defaults and prefers Classic solid modules, while `quietZone` behaves exactly as it does in screen mode. Four modules remains the recommended default. `getPrintGuidance()` converts a chosen physical size into module millimeters/pixels so print layouts can be checked before production testing.
262
262
 
263
263
  Centered logos support `size: "auto"`, which estimates a conservative ECC-aware ratio from code utilization and rendering choices. `findMaxSafeLogoSize()` can additionally probe ImageData output and empirically search for the largest size that still decodes.
264
264
 
@@ -1104,7 +1104,7 @@ Scans one frame from an HTML video element. By default, if the video is displaye
1104
1104
 
1105
1105
  ### `startCameraScanner(video, options?)`
1106
1106
 
1107
- Starts a reusable live-camera scanning loop. On supported browsers it requests continuous focus/exposure/white-balance camera modes and scans the CSS-visible preview crop. Modern browsers use a **dual-worker camera engine**: a lightweight fresh-frame worker continuously runs normal finder/geometry/decode attempts, while an independent recovery worker retains the complete high-resolution, Auto Color, precise-alignment, perspective, multi-frame, ECC, and damaged-code recovery stack. A slow recovery attempt therefore cannot prevent the fast worker from inspecting a newer camera frame. Finder detection remains JavaScript; optional WASM accelerates grayscale/binary preprocessing and CRC beneath the same detector. The scheduler uses `requestVideoFrameCallback()` when available and does not queue stale fast-path frames. Normal camera acquisition requests an environment camera around 1280×720 and crops/resizes the visible preview to a 640 px working bitmap **before** transferring it to the worker. Once a candidate validates structure, Spectrum ECC, and CRC, scanning returns immediately. If the fast worker misses, full recovery runs concurrently on a fresh frame at up to 960 px. Strong finder evidence dispatches recovery quickly; finder-less frames still receive periodic full recovery so severe color casts or damaged locators retain the same rescue paths. QuadQR Auto Color crop profiles, center-weighted histograms, threshold bracketing, precise alignment, projective recovery, QR-region enhancement, multi-frame confidence fusion, and soft-decision Spectrum ECC are unchanged. `cameraHighResolutionMaxDimension` defaults to 960. The optional `onDiagnostic(event)` callback exposes whether an event came from the fast or recovery worker, finder candidates, active locator method, crop/geometry/version hypothesis, recovery method, timing, and scan dimensions. `onResult(result, frame)` receives the exact frame that decoded, including enhanced recovery pixels when applicable, so UIs can keep the frozen frame and finder overlay aligned.
1107
+ Starts a reusable live-camera scanning loop. On supported browsers it requests continuous focus/exposure/white-balance camera modes and scans the CSS-visible preview crop. Modern browsers use a **dual-worker camera engine**: a lightweight fresh-frame worker continuously runs normal finder/geometry/decode attempts, while an independent recovery worker retains the complete high-resolution, Auto Color, precise-alignment, perspective, multi-frame, ECC, and damaged-code recovery stack. A slow recovery attempt therefore cannot prevent the fast worker from inspecting a newer camera frame. Finder detection remains JavaScript; optional WASM accelerates grayscale/binary preprocessing and CRC beneath the same detector. The scheduler uses `requestVideoFrameCallback()` when available and does not queue stale fast-path frames. Normal camera acquisition requests an environment camera around 1280×720 and crops/resizes the visible preview to a 640 px working bitmap **before** transferring it to the worker. Once a candidate validates structure, Spectrum ECC, and CRC, scanning returns immediately. If the fast worker misses, full recovery runs concurrently on a fresh frame at up to 960 px. Strong finder evidence dispatches recovery quickly. Finder-less frames remain on the lightweight fresh-frame detector only, so pointing the camera at an empty scene never starts Auto Color or deeper recovery work. QuadQR Auto Color crop profiles, center-weighted histograms, threshold bracketing, precise alignment, projective recovery, QR-region enhancement, multi-frame confidence fusion, and soft-decision Spectrum ECC are unchanged. `cameraHighResolutionMaxDimension` defaults to 960. The optional `onDiagnostic(event)` callback exposes whether an event came from the fast or recovery worker, finder candidates, active locator method, crop/geometry/version hypothesis, recovery method, timing, and scan dimensions. `onResult(result, frame)` receives the exact frame that decoded, including enhanced recovery pixels when applicable, so UIs can keep the frozen frame and finder overlay aligned.
1108
1108
 
1109
1109
  ### `getVersionInfo(version, options?)`
1110
1110
 
@@ -1281,4 +1281,4 @@ AGPL v3.0. See `LICENSE`.
1281
1281
 
1282
1282
  **Experimental / research project**
1283
1283
 
1284
- QuadQR is actively evolving. Format details may change between versions until the wire format is considered stable.
1284
+ QuadQR is actively evolving. Format details may change between versions until the wire format is considered stable.
package/SPECIFICATION.md CHANGED
@@ -253,9 +253,9 @@ inset
253
253
 
254
254
  ### Print mode
255
255
 
256
- `mode: "print"` applies conservative defaults:
256
+ `mode: "print"` applies print-oriented defaults:
257
257
 
258
- - minimum quiet zone of 4 modules unless explicitly overridden;
258
+ - caller-controlled quiet-zone sizing, identical to screen mode (4 modules recommended by default);
259
259
  - print-safe darker RGB primaries;
260
260
  - Classic solid-module rendering by default;
261
261
  - physical-size guidance through `getPrintGuidance()`.
@@ -326,9 +326,10 @@ function processFrame(bitmap, source, frameNumber) {
326
326
  let allowFinderRecovery = false;
327
327
  let allowAutoEnhance = false;
328
328
  try {
329
- allowFinderRecovery = scanOptions.finderRecovery !== false &&
329
+ const fastPipeline = scanOptions.cameraPipelineMode === "fast";
330
+ allowFinderRecovery = !fastPipeline && scanOptions.finderRecovery !== false &&
330
331
  missStreak > 0 && ((missStreak - 1) % cameraFinderRecoveryEvery === 0);
331
- allowAutoEnhance = scanOptions.autoEnhanceRecovery !== false &&
332
+ allowAutoEnhance = !fastPipeline && scanOptions.autoEnhanceRecovery !== false &&
332
333
  missStreak > 0 && ((missStreak - 1) % cameraAutoEnhanceEvery === 0);
333
334
  const method = allowAutoEnhance
334
335
  ? "progressive-color-recovery"
@@ -326,9 +326,10 @@ function processFrame(bitmap, source, frameNumber) {
326
326
  let allowFinderRecovery = false;
327
327
  let allowAutoEnhance = false;
328
328
  try {
329
- allowFinderRecovery = scanOptions.finderRecovery !== false &&
329
+ const fastPipeline = scanOptions.cameraPipelineMode === "fast";
330
+ allowFinderRecovery = !fastPipeline && scanOptions.finderRecovery !== false &&
330
331
  missStreak > 0 && ((missStreak - 1) % cameraFinderRecoveryEvery === 0);
331
- allowAutoEnhance = scanOptions.autoEnhanceRecovery !== false &&
332
+ allowAutoEnhance = !fastPipeline && scanOptions.autoEnhanceRecovery !== false &&
332
333
  missStreak > 0 && ((missStreak - 1) % cameraAutoEnhanceEvery === 0);
333
334
  const method = allowAutoEnhance
334
335
  ? "progressive-color-recovery"
@@ -3056,10 +3056,7 @@ function svgLogoHref(source) {
3056
3056
 
3057
3057
  function resolveRenderSizing(options, matrixSize) {
3058
3058
  const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
3059
- const requestedQuietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
3060
- const quietZone = mode === RENDER_MODES.PRINT && options.allowUnsafePrintQuietZone !== true
3061
- ? Math.max(4, requestedQuietZone)
3062
- : requestedQuietZone;
3059
+ const quietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
3063
3060
  const totalModules = matrixSize + quietZone * 2;
3064
3061
  const hasImageSize = options.imageSize !== undefined && options.imageSize !== null;
3065
3062
  const hasModuleSize = options.moduleSize !== undefined && options.moduleSize !== null;
@@ -6422,6 +6419,12 @@ async function startCameraScannerWorker(video, options = {}) {
6422
6419
  const fastWorkerOptions = {
6423
6420
  ...options,
6424
6421
  cameraPipelineMode: "fast",
6422
+ // The fresh-frame worker is intentionally detection/decode only. Recovery
6423
+ // must never become more expensive merely because the camera has been
6424
+ // looking at an empty scene for a while. The parallel recovery worker is
6425
+ // armed only after finder/geometry evidence says a QuadQR candidate is in
6426
+ // view.
6427
+ finderRecovery: false,
6425
6428
  cameraHighResolutionRecovery: false,
6426
6429
  cameraAutoColorRecovery: false,
6427
6430
  autoEnhanceRecovery: false,
@@ -6464,7 +6467,7 @@ async function startCameraScannerWorker(video, options = {}) {
6464
6467
  );
6465
6468
  const recoveryStrongFinderInterval = Math.max(80, Number(options.cameraRecoveryStrongFinderInterval ?? 120));
6466
6469
  const recoveryWeakFinderInterval = Math.max(recoveryStrongFinderInterval, Number(options.cameraRecoveryWeakFinderInterval ?? 260));
6467
- const recoveryNoFinderInterval = Math.max(recoveryWeakFinderInterval, Number(options.cameraRecoveryNoFinderInterval ?? 850));
6470
+ const recoveryWeakFinderFrames = Math.max(2, Math.round(options.cameraRecoveryWeakFinderFrames ?? 2));
6468
6471
  const useVideoFrameCallback = options.useVideoFrameCallback !== false &&
6469
6472
  typeof video.requestVideoFrameCallback === "function";
6470
6473
 
@@ -6481,6 +6484,7 @@ async function startCameraScannerWorker(video, options = {}) {
6481
6484
  let frameNumber = 0;
6482
6485
  let requestToken = 0;
6483
6486
  let recoveryToken = 0;
6487
+ let weakFinderStreak = 0;
6484
6488
 
6485
6489
  const diagnosticsEnabled = typeof options.onDiagnostic === "function";
6486
6490
  const emitDiagnostic = (event) => {
@@ -6532,7 +6536,7 @@ async function startCameraScannerWorker(video, options = {}) {
6532
6536
  emitDiagnostic({
6533
6537
  type: "camera-ready",
6534
6538
  method: "camera-dual-worker",
6535
- message: `Camera ready · ${settings.width ?? video.videoWidth}×${settings.height ?? video.videoHeight} · fast fresh-frame scanner + parallel recovery`,
6539
+ message: `Camera ready · ${settings.width ?? video.videoWidth}×${settings.height ?? video.videoHeight} · fast fresh-frame scanner + candidate-gated parallel recovery`,
6536
6540
  camera: {
6537
6541
  width: settings.width ?? video.videoWidth,
6538
6542
  height: settings.height ?? video.videoHeight,
@@ -6616,9 +6620,7 @@ async function startCameraScannerWorker(video, options = {}) {
6616
6620
  method: "parallel-full-recovery",
6617
6621
  frame: triggerFrame,
6618
6622
  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"
6623
+ message: `QuadQR candidate detected (${finderCount} finder${finderCount === 1 ? "" : "s"}) · full recovery running in parallel`
6622
6624
  });
6623
6625
 
6624
6626
  const recoveryPayload = { bitmap, source: captured.source, frame: triggerFrame };
@@ -6661,12 +6663,26 @@ async function startCameraScannerWorker(video, options = {}) {
6661
6663
  const maybeDispatchRecovery = (workerResult, triggerFrame) => {
6662
6664
  if (stopped || recoveryBusy) return;
6663
6665
  const finderCount = maximumFinderCount(workerResult);
6666
+
6667
+ // Do not run Auto Color, high-resolution, multi-frame, or damaged-code
6668
+ // recovery just because time has passed. An empty scene stays on the cheap
6669
+ // fresh-frame detector forever. Two or more finders are strong evidence and
6670
+ // arm recovery immediately. One finder is treated as weak evidence and must
6671
+ // persist across consecutive fresh frames before recovery is allowed.
6672
+ let minimumInterval = null;
6673
+ if (finderCount >= 2) {
6674
+ weakFinderStreak = 0;
6675
+ minimumInterval = recoveryStrongFinderInterval;
6676
+ } else if (finderCount === 1) {
6677
+ weakFinderStreak++;
6678
+ if (weakFinderStreak < recoveryWeakFinderFrames) return;
6679
+ minimumInterval = recoveryWeakFinderInterval;
6680
+ } else {
6681
+ weakFinderStreak = 0;
6682
+ return;
6683
+ }
6684
+
6664
6685
  const elapsed = nowMs() - lastRecoveryStartedAt;
6665
- const minimumInterval = finderCount >= 2
6666
- ? recoveryStrongFinderInterval
6667
- : finderCount === 1
6668
- ? recoveryWeakFinderInterval
6669
- : recoveryNoFinderInterval;
6670
6686
  if (elapsed >= minimumInterval) void runRecovery(triggerFrame, finderCount);
6671
6687
  };
6672
6688
 
package/dist/quadqr.js CHANGED
@@ -9241,10 +9241,7 @@ function svgLogoHref(source) {
9241
9241
 
9242
9242
  function resolveRenderSizing(options, matrixSize) {
9243
9243
  const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
9244
- const requestedQuietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
9245
- const quietZone = mode === RENDER_MODES.PRINT && options.allowUnsafePrintQuietZone !== true
9246
- ? Math.max(4, requestedQuietZone)
9247
- : requestedQuietZone;
9244
+ const quietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
9248
9245
  const totalModules = matrixSize + quietZone * 2;
9249
9246
  const hasImageSize = options.imageSize !== undefined && options.imageSize !== null;
9250
9247
  const hasModuleSize = options.moduleSize !== undefined && options.moduleSize !== null;
@@ -12607,6 +12604,12 @@ async function startCameraScannerWorker(video, options = {}) {
12607
12604
  const fastWorkerOptions = {
12608
12605
  ...options,
12609
12606
  cameraPipelineMode: "fast",
12607
+ // The fresh-frame worker is intentionally detection/decode only. Recovery
12608
+ // must never become more expensive merely because the camera has been
12609
+ // looking at an empty scene for a while. The parallel recovery worker is
12610
+ // armed only after finder/geometry evidence says a QuadQR candidate is in
12611
+ // view.
12612
+ finderRecovery: false,
12610
12613
  cameraHighResolutionRecovery: false,
12611
12614
  cameraAutoColorRecovery: false,
12612
12615
  autoEnhanceRecovery: false,
@@ -12649,7 +12652,7 @@ async function startCameraScannerWorker(video, options = {}) {
12649
12652
  );
12650
12653
  const recoveryStrongFinderInterval = Math.max(80, Number(options.cameraRecoveryStrongFinderInterval ?? 120));
12651
12654
  const recoveryWeakFinderInterval = Math.max(recoveryStrongFinderInterval, Number(options.cameraRecoveryWeakFinderInterval ?? 260));
12652
- const recoveryNoFinderInterval = Math.max(recoveryWeakFinderInterval, Number(options.cameraRecoveryNoFinderInterval ?? 850));
12655
+ const recoveryWeakFinderFrames = Math.max(2, Math.round(options.cameraRecoveryWeakFinderFrames ?? 2));
12653
12656
  const useVideoFrameCallback = options.useVideoFrameCallback !== false &&
12654
12657
  typeof video.requestVideoFrameCallback === "function";
12655
12658
 
@@ -12666,6 +12669,7 @@ async function startCameraScannerWorker(video, options = {}) {
12666
12669
  let frameNumber = 0;
12667
12670
  let requestToken = 0;
12668
12671
  let recoveryToken = 0;
12672
+ let weakFinderStreak = 0;
12669
12673
 
12670
12674
  const diagnosticsEnabled = typeof options.onDiagnostic === "function";
12671
12675
  const emitDiagnostic = (event) => {
@@ -12717,7 +12721,7 @@ async function startCameraScannerWorker(video, options = {}) {
12717
12721
  emitDiagnostic({
12718
12722
  type: "camera-ready",
12719
12723
  method: "camera-dual-worker",
12720
- message: `Camera ready · ${settings.width ?? video.videoWidth}×${settings.height ?? video.videoHeight} · fast fresh-frame scanner + parallel recovery`,
12724
+ message: `Camera ready · ${settings.width ?? video.videoWidth}×${settings.height ?? video.videoHeight} · fast fresh-frame scanner + candidate-gated parallel recovery`,
12721
12725
  camera: {
12722
12726
  width: settings.width ?? video.videoWidth,
12723
12727
  height: settings.height ?? video.videoHeight,
@@ -12801,9 +12805,7 @@ async function startCameraScannerWorker(video, options = {}) {
12801
12805
  method: "parallel-full-recovery",
12802
12806
  frame: triggerFrame,
12803
12807
  finderCount,
12804
- message: finderCount > 0
12805
- ? `Fast frame saw ${finderCount} finder${finderCount === 1 ? "" : "s"} · full recovery running in parallel`
12806
- : "Periodic full recovery running in parallel while fast fresh-frame scanning continues"
12808
+ message: `QuadQR candidate detected (${finderCount} finder${finderCount === 1 ? "" : "s"}) · full recovery running in parallel`
12807
12809
  });
12808
12810
 
12809
12811
  const recoveryPayload = { bitmap, source: captured.source, frame: triggerFrame };
@@ -12846,12 +12848,26 @@ async function startCameraScannerWorker(video, options = {}) {
12846
12848
  const maybeDispatchRecovery = (workerResult, triggerFrame) => {
12847
12849
  if (stopped || recoveryBusy) return;
12848
12850
  const finderCount = maximumFinderCount(workerResult);
12851
+
12852
+ // Do not run Auto Color, high-resolution, multi-frame, or damaged-code
12853
+ // recovery just because time has passed. An empty scene stays on the cheap
12854
+ // fresh-frame detector forever. Two or more finders are strong evidence and
12855
+ // arm recovery immediately. One finder is treated as weak evidence and must
12856
+ // persist across consecutive fresh frames before recovery is allowed.
12857
+ let minimumInterval = null;
12858
+ if (finderCount >= 2) {
12859
+ weakFinderStreak = 0;
12860
+ minimumInterval = recoveryStrongFinderInterval;
12861
+ } else if (finderCount === 1) {
12862
+ weakFinderStreak++;
12863
+ if (weakFinderStreak < recoveryWeakFinderFrames) return;
12864
+ minimumInterval = recoveryWeakFinderInterval;
12865
+ } else {
12866
+ weakFinderStreak = 0;
12867
+ return;
12868
+ }
12869
+
12849
12870
  const elapsed = nowMs() - lastRecoveryStartedAt;
12850
- const minimumInterval = finderCount >= 2
12851
- ? recoveryStrongFinderInterval
12852
- : finderCount === 1
12853
- ? recoveryWeakFinderInterval
12854
- : recoveryNoFinderInterval;
12855
12871
  if (elapsed >= minimumInterval) void runRecovery(triggerFrame, finderCount);
12856
12872
  };
12857
12873
 
@@ -9071,10 +9071,7 @@ function svgLogoHref(source) {
9071
9071
 
9072
9072
  function resolveRenderSizing(options, matrixSize) {
9073
9073
  const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
9074
- const requestedQuietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
9075
- const quietZone = mode === RENDER_MODES.PRINT && options.allowUnsafePrintQuietZone !== true
9076
- ? Math.max(4, requestedQuietZone)
9077
- : requestedQuietZone;
9074
+ const quietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
9078
9075
  const totalModules = matrixSize + quietZone * 2;
9079
9076
  const hasImageSize = options.imageSize !== undefined && options.imageSize !== null;
9080
9077
  const hasModuleSize = options.moduleSize !== undefined && options.moduleSize !== null;
@@ -12428,6 +12425,12 @@ async function startCameraScannerWorker(video, options = {}) {
12428
12425
  const fastWorkerOptions = {
12429
12426
  ...options,
12430
12427
  cameraPipelineMode: "fast",
12428
+ // The fresh-frame worker is intentionally detection/decode only. Recovery
12429
+ // must never become more expensive merely because the camera has been
12430
+ // looking at an empty scene for a while. The parallel recovery worker is
12431
+ // armed only after finder/geometry evidence says a QuadQR candidate is in
12432
+ // view.
12433
+ finderRecovery: false,
12431
12434
  cameraHighResolutionRecovery: false,
12432
12435
  cameraAutoColorRecovery: false,
12433
12436
  autoEnhanceRecovery: false,
@@ -12470,7 +12473,7 @@ async function startCameraScannerWorker(video, options = {}) {
12470
12473
  );
12471
12474
  const recoveryStrongFinderInterval = Math.max(80, Number(options.cameraRecoveryStrongFinderInterval ?? 120));
12472
12475
  const recoveryWeakFinderInterval = Math.max(recoveryStrongFinderInterval, Number(options.cameraRecoveryWeakFinderInterval ?? 260));
12473
- const recoveryNoFinderInterval = Math.max(recoveryWeakFinderInterval, Number(options.cameraRecoveryNoFinderInterval ?? 850));
12476
+ const recoveryWeakFinderFrames = Math.max(2, Math.round(options.cameraRecoveryWeakFinderFrames ?? 2));
12474
12477
  const useVideoFrameCallback = options.useVideoFrameCallback !== false &&
12475
12478
  typeof video.requestVideoFrameCallback === "function";
12476
12479
 
@@ -12487,6 +12490,7 @@ async function startCameraScannerWorker(video, options = {}) {
12487
12490
  let frameNumber = 0;
12488
12491
  let requestToken = 0;
12489
12492
  let recoveryToken = 0;
12493
+ let weakFinderStreak = 0;
12490
12494
 
12491
12495
  const diagnosticsEnabled = typeof options.onDiagnostic === "function";
12492
12496
  const emitDiagnostic = (event) => {
@@ -12538,7 +12542,7 @@ async function startCameraScannerWorker(video, options = {}) {
12538
12542
  emitDiagnostic({
12539
12543
  type: "camera-ready",
12540
12544
  method: "camera-dual-worker",
12541
- message: `Camera ready · ${settings.width ?? video.videoWidth}×${settings.height ?? video.videoHeight} · fast fresh-frame scanner + parallel recovery`,
12545
+ message: `Camera ready · ${settings.width ?? video.videoWidth}×${settings.height ?? video.videoHeight} · fast fresh-frame scanner + candidate-gated parallel recovery`,
12542
12546
  camera: {
12543
12547
  width: settings.width ?? video.videoWidth,
12544
12548
  height: settings.height ?? video.videoHeight,
@@ -12622,9 +12626,7 @@ async function startCameraScannerWorker(video, options = {}) {
12622
12626
  method: "parallel-full-recovery",
12623
12627
  frame: triggerFrame,
12624
12628
  finderCount,
12625
- message: finderCount > 0
12626
- ? `Fast frame saw ${finderCount} finder${finderCount === 1 ? "" : "s"} · full recovery running in parallel`
12627
- : "Periodic full recovery running in parallel while fast fresh-frame scanning continues"
12629
+ message: `QuadQR candidate detected (${finderCount} finder${finderCount === 1 ? "" : "s"}) · full recovery running in parallel`
12628
12630
  });
12629
12631
 
12630
12632
  const recoveryPayload = { bitmap, source: captured.source, frame: triggerFrame };
@@ -12667,12 +12669,26 @@ async function startCameraScannerWorker(video, options = {}) {
12667
12669
  const maybeDispatchRecovery = (workerResult, triggerFrame) => {
12668
12670
  if (stopped || recoveryBusy) return;
12669
12671
  const finderCount = maximumFinderCount(workerResult);
12672
+
12673
+ // Do not run Auto Color, high-resolution, multi-frame, or damaged-code
12674
+ // recovery just because time has passed. An empty scene stays on the cheap
12675
+ // fresh-frame detector forever. Two or more finders are strong evidence and
12676
+ // arm recovery immediately. One finder is treated as weak evidence and must
12677
+ // persist across consecutive fresh frames before recovery is allowed.
12678
+ let minimumInterval = null;
12679
+ if (finderCount >= 2) {
12680
+ weakFinderStreak = 0;
12681
+ minimumInterval = recoveryStrongFinderInterval;
12682
+ } else if (finderCount === 1) {
12683
+ weakFinderStreak++;
12684
+ if (weakFinderStreak < recoveryWeakFinderFrames) return;
12685
+ minimumInterval = recoveryWeakFinderInterval;
12686
+ } else {
12687
+ weakFinderStreak = 0;
12688
+ return;
12689
+ }
12690
+
12670
12691
  const elapsed = nowMs() - lastRecoveryStartedAt;
12671
- const minimumInterval = finderCount >= 2
12672
- ? recoveryStrongFinderInterval
12673
- : finderCount === 1
12674
- ? recoveryWeakFinderInterval
12675
- : recoveryNoFinderInterval;
12676
12692
  if (elapsed >= minimumInterval) void runRecovery(triggerFrame, finderCount);
12677
12693
  };
12678
12694
 
package/docs/API.md CHANGED
@@ -209,7 +209,7 @@ const result = scanVideoFrame(video);
209
209
 
210
210
  ### `startCameraScanner(video, options?)`
211
211
 
212
- Starts live camera scanning. On browsers that expose camera controls, QuadQR requests continuous autofocus, exposure, and white balance. Modern browsers use two module Web Workers by default: a **fast fresh-frame worker** for normal finder/geometry/decode attempts and a separate **full-recovery worker** for the same perspective, color, high-resolution, ECC, damaged-code, and multi-frame recovery stack used by the main scanner. The recovery worker is lazy and independent, so a difficult old frame cannot block the fast worker from inspecting a newer camera frame. Finder detection itself is JavaScript; WASM only accelerates deterministic grayscale/binary preprocessing and CRC. The loop uses `requestVideoFrameCallback()` when available, drops stale fast-path frames, and measures `scanInterval` from scan start. Worker mode defaults to a 33 ms minimum cadence; automatic main-thread fallback uses 80 ms. The default camera request is approximately 1280×720, while the CSS-visible preview crop is resized to a 640 px working bitmap before crossing the worker boundary. Finder acquisition uses the streaming 1:1:3:1:1 RGB-value pass, direct cross-checks, local-threshold fallback, and directional module-size/version estimation. Once a geometry candidate validates structure, ECC, and CRC, it returns immediately. A miss with strong finder evidence dispatches parallel full recovery quickly; frames with weak or zero finder evidence still receive periodic full recovery so color-cast/damaged locator cases remain recoverable. That recovery can use up to 960 px and retains the full QuadQR Auto Color crop sequence, center-weighted color recovery, threshold bracketing, precise alignment, perspective recovery, affine cross-channel calibration, QR-region enhancement, multi-frame confidence fusion, erasure decoding, and soft-decision Spectrum ECC.
212
+ Starts live camera scanning. On browsers that expose camera controls, QuadQR requests continuous autofocus, exposure, and white balance. Modern browsers use two module Web Workers by default: a **fast fresh-frame worker** for normal finder/geometry/decode attempts and a separate **full-recovery worker** for the same perspective, color, high-resolution, ECC, damaged-code, and multi-frame recovery stack used by the main scanner. The recovery worker is lazy and independent, so a difficult old frame cannot block the fast worker from inspecting a newer camera frame. Finder detection itself is JavaScript; WASM only accelerates deterministic grayscale/binary preprocessing and CRC. The loop uses `requestVideoFrameCallback()` when available, drops stale fast-path frames, and measures `scanInterval` from scan start. Worker mode defaults to a 33 ms minimum cadence; automatic main-thread fallback uses 80 ms. The default camera request is approximately 1280×720, while the CSS-visible preview crop is resized to a 640 px working bitmap before crossing the worker boundary. Finder acquisition uses the streaming 1:1:3:1:1 RGB-value pass, direct cross-checks, local-threshold fallback, and directional module-size/version estimation. Once a geometry candidate validates structure, ECC, and CRC, it returns immediately. A miss with strong finder evidence dispatches parallel full recovery quickly. One finder must persist across consecutive fresh frames before recovery is armed, while zero-finder frames stay on the lightweight detector only. That recovery can use up to 960 px and retains the full QuadQR Auto Color crop sequence, center-weighted color recovery, threshold bracketing, precise alignment, perspective recovery, affine cross-channel calibration, QR-region enhancement, multi-frame confidence fusion, erasure decoding, and soft-decision Spectrum ECC.
213
213
 
214
214
  ```js
215
215
  const scanner = await startCameraScanner(video, {
@@ -235,7 +235,7 @@ const scanner = await startCameraScanner(video, {
235
235
  cameraHighResolutionEvery: 2,
236
236
  cameraRecoveryStrongFinderInterval: 120,
237
237
  cameraRecoveryWeakFinderInterval: 260,
238
- cameraRecoveryNoFinderInterval: 850,
238
+ cameraRecoveryWeakFinderFrames: 2,
239
239
  onResult(result) {
240
240
  console.log(result);
241
241
  },
@@ -531,7 +531,7 @@ All render APIs accept:
531
531
  { mode: "screen" | "print" }
532
532
  ```
533
533
 
534
- Print mode uses a darker print-safe palette, forces Classic modules by default, and enforces a minimum 4-module quiet zone unless `allowUnsafePrintQuietZone: true` is explicitly set.
534
+ Print mode uses a darker print-safe palette and forces Classic modules by default. `quietZone` has the same behavior in print and screen modes, including support for values below the recommended four-module default.
535
535
 
536
536
  ### `getPrintGuidance(codeOrMatrix, options?)`
537
537
 
package/docs/CLI.md CHANGED
@@ -1,167 +1,167 @@
1
- # Command Line Interface
2
-
3
- The npm package includes the `quadqr` executable and can be used directly through `npx`.
4
-
5
- ## Encode text
6
-
7
- ```bash
8
- npx quadqr-js encode "Hello QuadQR" -o hello.png
9
- npx quadqr-js encode "Hello QuadQR" -o hello.svg
10
- ```
11
-
12
- Optional encoding controls:
13
-
14
- ```bash
15
- npx quadqr-js encode "Hello" --ecc M --version auto --image-size 720 --quiet-zone 4 -o hello.png
16
- ```
17
-
18
- Use the print-safe rendering profile when the symbol is intended for physical output:
19
-
20
- ```bash
21
- npx quadqr-js encode "Print me" --print -o print.svg
22
- ```
23
-
24
- Print mode enforces a minimum four-module quiet zone and uses the print-safe rendering defaults.
25
-
26
- ## Compression
27
-
28
- Compression works directly with normal text payloads:
29
-
30
- ```bash
31
- npx quadqr-js encode "repeat repeat repeat repeat" \
32
- --compression auto \
33
- -o compressed.png
34
- ```
35
-
36
- Compression modes are `none`, `auto`, `smart`, `brotli`, `deflate`, and `lz`. `auto` performs one balanced comparison using LZ level 6, DEFLATE level 6, and Brotli quality 6. `smart` is CPU-heavy: it starts with the same pass and only escalates to stronger DEFLATE/Brotli levels when a smaller QuadQR version is realistically reachable. If envelope overhead would erase the gain, Auto/Smart leave the original payload untouched. No separate payload mode is required.
37
-
38
- Explicit codecs can select a level:
39
-
40
- ```bash
41
- npx quadqr-js encode "structured payload" --compression lz --compression-level 9 -o lz.png
42
- npx quadqr-js encode "structured payload" --compression deflate --compression-level 9 -o deflate.png
43
- npx quadqr-js encode "structured payload" --compression brotli --compression-level 11 -o brotli.png
44
- ```
45
-
46
- LZ and DEFLATE accept levels `1..9` and default to 6. Brotli accepts qualities `0..11` and defaults to 11. `--compression-level` is ignored by Auto/Smart because those modes manage their own staged levels.
47
-
48
- ## Signed QuadQR
49
-
50
- Generate an Ed25519 signing-key bundle:
51
-
52
- ```bash
53
- npx quadqr-js signkeygen -o quadqr-signing-key.json
54
- ```
55
-
56
- Keep this file secret because it contains the private signing key. Encode a signed payload with:
57
-
58
- ```bash
59
- npx quadqr-js encode "Offline-verifiable ticket" \
60
- --sign-key quadqr-signing-key.json \
61
- -o signed.png
62
- ```
63
-
64
- The generated key bundle contains both keys plus a compact `keyId`. Only the private key signs. The public key is **not embedded** in the QuadQR by default and should be distributed separately to trusted scanners or stored in a trusted-key registry.
65
-
66
- To override the identifier stored in the symbol:
67
-
68
- ```bash
69
- npx quadqr-js encode "Offline-verifiable ticket" \
70
- --sign-key quadqr-signing-key.json \
71
- --key-id event-main-2026 \
72
- -o signed.png
73
- ```
74
-
75
- Signing can be combined with password or raw-key encryption:
76
-
77
- ```bash
78
- npx quadqr-js encode "Signed and private" \
79
- --sign-key quadqr-signing-key.json \
80
- --password "my-password" \
81
- -o signed-secure.png
82
- ```
83
-
84
- ## Decode an image
85
-
86
- ```bash
87
- npx quadqr-js decode hello.png
88
- npx quadqr-js decode signed.png --verify-key quadqr-signing-key.json
89
- ```
90
-
91
- For an unencrypted text payload, the decoded text is printed to stdout. To verify a signed symbol against a trusted public key, pass the signing bundle with `--verify-key`.
92
-
93
- Add scanner diagnostics without changing the normal stdout payload:
94
-
95
- ```bash
96
- npx quadqr-js decode hello.png --debug
97
- ```
98
-
99
- Diagnostics are written to stderr and include confidence, geometry/color confidence, ECC utilization, signing state, and the scanner diagnostics object when available.
100
-
101
- ## Password-protected payloads
102
-
103
- Encode:
104
-
105
- ```bash
106
- npx quadqr-js encode "Private data" --password "my-password" -o secure.png
107
- ```
108
-
109
- Decode:
110
-
111
- ```bash
112
- npx quadqr-js decode secure.png --password "my-password"
113
- ```
114
-
115
- If an encrypted symbol is decoded without a credential, the CLI reports that decryption is required instead of exposing plaintext.
116
-
117
- ## Raw 256-bit key mode
118
-
119
- Generate a random 256-bit encryption key:
120
-
121
- ```bash
122
- npx quadqr-js keygen
123
- ```
124
-
125
- The output is a 64-character hexadecimal key. Store it securely and do not place it inside the same QuadQR symbol.
126
-
127
- Encode using the key:
128
-
129
- ```bash
130
- npx quadqr-js encode "Application secret" --key <64-hex-key> -o secure-key.png
131
- ```
132
-
133
- Decode using the key:
134
-
135
- ```bash
136
- npx quadqr-js decode secure-key.png --key <64-hex-key>
137
- ```
138
-
139
- ## Options
140
-
141
- | Option | Purpose |
142
- | --- | --- |
143
- | `-o, --output <file>` | Output PNG/SVG path, or signing-key JSON path for `signkeygen` |
144
- | `--ecc <L|M|Q|H>` | QuadQR ECC profile. Default: `M` |
145
- | `--version <auto|1..40>` | Symbol version. Default: `auto` |
146
- | `--compression <mode>` | `none`, `auto`, `smart`, `brotli`, `deflate`, or `lz`. Default: `auto` |
147
- | `--compression-level <n>` | Explicit LZ/DEFLATE `1..9` or Brotli `0..11` encoder level |
148
- | `--high-density` | Enable experimental Triangle16 High Density Mode |
149
- | `--sign-key <file>` | Sign using a key bundle generated by `signkeygen` |
150
- | `--key-id <id>` | Override the signing key ID stored in the symbol |
151
- | `--embed-public-key` | Explicit compatibility mode that embeds the public key |
152
- | `--verify-key <file>` | Verify a signed symbol with a trusted Ed25519 key bundle |
153
- | `--password <text>` | Password-mode encryption/decryption |
154
- | `--key <hex>` | Raw 256-bit key encryption/decryption |
155
- | `--print` | Use the print-safe render profile |
156
- | `--image-size <px>` | Exact square output size in pixels. Default: `720` |
157
- | `--module-size <px>` | Legacy pixels-per-module sizing. Used when `--image-size` is omitted |
158
- | `--quiet-zone <modules>` | Quiet-zone size in modules. Default: `4` |
159
- | `--debug` | Emit scanner diagnostics to stderr when decoding |
160
- | `-h, --help` | Show CLI help |
161
-
162
- Password mode and raw-key mode are mutually exclusive for a single operation.
163
-
164
-
165
- ## High Density Mode (Experimental)
166
-
167
- Use `--high-density` to enable the experimental Triangle16 layout with 16 states and 4 raw bits per body cell. Decode is automatic; no matching decode flag is required.
1
+ # Command Line Interface
2
+
3
+ The npm package includes the `quadqr` executable and can be used directly through `npx`.
4
+
5
+ ## Encode text
6
+
7
+ ```bash
8
+ npx quadqr-js encode "Hello QuadQR" -o hello.png
9
+ npx quadqr-js encode "Hello QuadQR" -o hello.svg
10
+ ```
11
+
12
+ Optional encoding controls:
13
+
14
+ ```bash
15
+ npx quadqr-js encode "Hello" --ecc M --version auto --image-size 720 --quiet-zone 4 -o hello.png
16
+ ```
17
+
18
+ Use the print-safe rendering profile when the symbol is intended for physical output:
19
+
20
+ ```bash
21
+ npx quadqr-js encode "Print me" --print -o print.svg
22
+ ```
23
+
24
+ Print mode uses the print-safe palette and Classic rendering defaults. `--quiet-zone` is honored unchanged, just as it is in screen mode; four modules remains recommended.
25
+
26
+ ## Compression
27
+
28
+ Compression works directly with normal text payloads:
29
+
30
+ ```bash
31
+ npx quadqr-js encode "repeat repeat repeat repeat" \
32
+ --compression auto \
33
+ -o compressed.png
34
+ ```
35
+
36
+ Compression modes are `none`, `auto`, `smart`, `brotli`, `deflate`, and `lz`. `auto` performs one balanced comparison using LZ level 6, DEFLATE level 6, and Brotli quality 6. `smart` is CPU-heavy: it starts with the same pass and only escalates to stronger DEFLATE/Brotli levels when a smaller QuadQR version is realistically reachable. If envelope overhead would erase the gain, Auto/Smart leave the original payload untouched. No separate payload mode is required.
37
+
38
+ Explicit codecs can select a level:
39
+
40
+ ```bash
41
+ npx quadqr-js encode "structured payload" --compression lz --compression-level 9 -o lz.png
42
+ npx quadqr-js encode "structured payload" --compression deflate --compression-level 9 -o deflate.png
43
+ npx quadqr-js encode "structured payload" --compression brotli --compression-level 11 -o brotli.png
44
+ ```
45
+
46
+ LZ and DEFLATE accept levels `1..9` and default to 6. Brotli accepts qualities `0..11` and defaults to 11. `--compression-level` is ignored by Auto/Smart because those modes manage their own staged levels.
47
+
48
+ ## Signed QuadQR
49
+
50
+ Generate an Ed25519 signing-key bundle:
51
+
52
+ ```bash
53
+ npx quadqr-js signkeygen -o quadqr-signing-key.json
54
+ ```
55
+
56
+ Keep this file secret because it contains the private signing key. Encode a signed payload with:
57
+
58
+ ```bash
59
+ npx quadqr-js encode "Offline-verifiable ticket" \
60
+ --sign-key quadqr-signing-key.json \
61
+ -o signed.png
62
+ ```
63
+
64
+ The generated key bundle contains both keys plus a compact `keyId`. Only the private key signs. The public key is **not embedded** in the QuadQR by default and should be distributed separately to trusted scanners or stored in a trusted-key registry.
65
+
66
+ To override the identifier stored in the symbol:
67
+
68
+ ```bash
69
+ npx quadqr-js encode "Offline-verifiable ticket" \
70
+ --sign-key quadqr-signing-key.json \
71
+ --key-id event-main-2026 \
72
+ -o signed.png
73
+ ```
74
+
75
+ Signing can be combined with password or raw-key encryption:
76
+
77
+ ```bash
78
+ npx quadqr-js encode "Signed and private" \
79
+ --sign-key quadqr-signing-key.json \
80
+ --password "my-password" \
81
+ -o signed-secure.png
82
+ ```
83
+
84
+ ## Decode an image
85
+
86
+ ```bash
87
+ npx quadqr-js decode hello.png
88
+ npx quadqr-js decode signed.png --verify-key quadqr-signing-key.json
89
+ ```
90
+
91
+ For an unencrypted text payload, the decoded text is printed to stdout. To verify a signed symbol against a trusted public key, pass the signing bundle with `--verify-key`.
92
+
93
+ Add scanner diagnostics without changing the normal stdout payload:
94
+
95
+ ```bash
96
+ npx quadqr-js decode hello.png --debug
97
+ ```
98
+
99
+ Diagnostics are written to stderr and include confidence, geometry/color confidence, ECC utilization, signing state, and the scanner diagnostics object when available.
100
+
101
+ ## Password-protected payloads
102
+
103
+ Encode:
104
+
105
+ ```bash
106
+ npx quadqr-js encode "Private data" --password "my-password" -o secure.png
107
+ ```
108
+
109
+ Decode:
110
+
111
+ ```bash
112
+ npx quadqr-js decode secure.png --password "my-password"
113
+ ```
114
+
115
+ If an encrypted symbol is decoded without a credential, the CLI reports that decryption is required instead of exposing plaintext.
116
+
117
+ ## Raw 256-bit key mode
118
+
119
+ Generate a random 256-bit encryption key:
120
+
121
+ ```bash
122
+ npx quadqr-js keygen
123
+ ```
124
+
125
+ The output is a 64-character hexadecimal key. Store it securely and do not place it inside the same QuadQR symbol.
126
+
127
+ Encode using the key:
128
+
129
+ ```bash
130
+ npx quadqr-js encode "Application secret" --key <64-hex-key> -o secure-key.png
131
+ ```
132
+
133
+ Decode using the key:
134
+
135
+ ```bash
136
+ npx quadqr-js decode secure-key.png --key <64-hex-key>
137
+ ```
138
+
139
+ ## Options
140
+
141
+ | Option | Purpose |
142
+ | --- | --- |
143
+ | `-o, --output <file>` | Output PNG/SVG path, or signing-key JSON path for `signkeygen` |
144
+ | `--ecc <L|M|Q|H>` | QuadQR ECC profile. Default: `M` |
145
+ | `--version <auto|1..40>` | Symbol version. Default: `auto` |
146
+ | `--compression <mode>` | `none`, `auto`, `smart`, `brotli`, `deflate`, or `lz`. Default: `auto` |
147
+ | `--compression-level <n>` | Explicit LZ/DEFLATE `1..9` or Brotli `0..11` encoder level |
148
+ | `--high-density` | Enable experimental Triangle16 High Density Mode |
149
+ | `--sign-key <file>` | Sign using a key bundle generated by `signkeygen` |
150
+ | `--key-id <id>` | Override the signing key ID stored in the symbol |
151
+ | `--embed-public-key` | Explicit compatibility mode that embeds the public key |
152
+ | `--verify-key <file>` | Verify a signed symbol with a trusted Ed25519 key bundle |
153
+ | `--password <text>` | Password-mode encryption/decryption |
154
+ | `--key <hex>` | Raw 256-bit key encryption/decryption |
155
+ | `--print` | Use the print-safe render profile |
156
+ | `--image-size <px>` | Exact square output size in pixels. Default: `720` |
157
+ | `--module-size <px>` | Legacy pixels-per-module sizing. Used when `--image-size` is omitted |
158
+ | `--quiet-zone <modules>` | Quiet-zone size in modules. Default: `4` |
159
+ | `--debug` | Emit scanner diagnostics to stderr when decoding |
160
+ | `-h, --help` | Show CLI help |
161
+
162
+ Password mode and raw-key mode are mutually exclusive for a single operation.
163
+
164
+
165
+ ## High Density Mode (Experimental)
166
+
167
+ Use `--high-density` to enable the experimental Triangle16 layout with 16 states and 4 raw bits per body cell. Decode is automatic; no matching decode flag is required.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quadqr-js",
3
- "version": "1.5.2",
3
+ "version": "1.5.4",
4
4
  "description": "QuadQR: experimental RGBW matrix code with optional experimental High Density Mode, Spectrum ECC 2.0, multi-frame camera scanning, advanced calibration, Reliability Lab, and 3D perspective recovery.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",