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.
- package/FORMAT.md +5 -5
- package/README.md +19 -8
- package/SPECIFICATION.md +12 -12
- package/dist/camera-scanner-worker.js +793 -0
- package/dist/esm/camera-scanner-worker.js +793 -0
- package/dist/esm/geometry.js +25 -10
- package/dist/esm/quadqr.js +843 -62
- package/dist/esm/vision.js +887 -122
- package/dist/esm/wasm.js +85 -12
- package/dist/quadqr.js +1803 -198
- package/dist/quadqr.min.js +2032 -274
- package/dist/wasm/quadqr-core.wasm +0 -0
- package/docs/API.md +13 -5
- package/docs/BROWSER_CDN.md +5 -5
- package/docs/CLI.md +167 -167
- package/docs/COMPRESSION.md +170 -170
- package/docs/GETTING_STARTED.md +1 -1
- package/docs/HIGH_DENSITY_MODE.md +100 -100
- package/docs/NODE.md +159 -159
- package/docs/README.md +62 -62
- package/docs/RELIABILITY_LAB.md +64 -64
- package/docs/TRIANGLE16.md +98 -98
- package/docs/WASM.md +30 -4
- package/package.json +5 -3
package/dist/esm/quadqr.js
CHANGED
|
@@ -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 =
|
|
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] =
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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 =
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
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
|
-
|
|
3877
|
-
|
|
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
|
-
|
|
3951
|
+
const spatialAttempt = {
|
|
3888
3952
|
classifier: classifierFromPaletteRgb(normalizedPalette, "raw"),
|
|
3889
3953
|
colorNormalization: "spatial-white-balanced"
|
|
3890
|
-
}
|
|
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
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
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
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
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
|
-
|
|
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:
|
|
5471
|
-
height: { ideal:
|
|
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
|
-
|
|
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 ??
|
|
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 ??
|
|
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
|
|
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
|
-
|
|
6234
|
+
scheduleNextScan();
|
|
6011
6235
|
};
|
|
6012
6236
|
|
|
6013
|
-
|
|
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,
|