@dcg-overseas/scanner 0.1.0

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/dist/index.cjs ADDED
@@ -0,0 +1,321 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var src_exports = {};
22
+ __export(src_exports, {
23
+ useScanner: () => useScanner
24
+ });
25
+ module.exports = __toCommonJS(src_exports);
26
+
27
+ // src/useScanner.ts
28
+ var import_react = require("react");
29
+ var import_cv_worker = require("@dcg-overseas/cv-worker");
30
+ var import_core = require("@dcg-overseas/core");
31
+ var SCAN_DIM = 720;
32
+ var VALID_FRAMES_REQUIRED = 2;
33
+ var QR_REALTIME_CROP_PX = 600;
34
+ var BRACKET_INSET = 12;
35
+ var BRACKET_LEN_RATIO = 0.08;
36
+ var STATUS_BAR_H = 40;
37
+ async function openCamera() {
38
+ return navigator.mediaDevices.getUserMedia({
39
+ video: { width: { ideal: 3840 }, height: { ideal: 2160 }, facingMode: { ideal: "environment" } },
40
+ audio: false
41
+ });
42
+ }
43
+ function countFound(result) {
44
+ const aruco = [0, 1, 2].filter((id) => result.arucoMarkers.some((m) => m.id === id)).length;
45
+ return aruco + (result.qrCode.found ? 1 : 0);
46
+ }
47
+ function useScanner() {
48
+ const videoRef = (0, import_react.useRef)(null);
49
+ const overlayRef = (0, import_react.useRef)(null);
50
+ const captureCanvasRef = (0, import_react.useRef)(null);
51
+ const streamRef = (0, import_react.useRef)(null);
52
+ const rafRef = (0, import_react.useRef)(null);
53
+ const detectingRef = (0, import_react.useRef)(false);
54
+ const validCountRef = (0, import_react.useRef)(0);
55
+ const overlayDimRef = (0, import_react.useRef)({ w: 0, h: 0 });
56
+ const offscreenRef = (0, import_react.useRef)(null);
57
+ const offscreenDimRef = (0, import_react.useRef)({ w: 0, h: 0 });
58
+ const qrCropCanvasRef = (0, import_react.useRef)(null);
59
+ const barcodeDetectorRef = (0, import_react.useRef)(void 0);
60
+ const qrValueRef = (0, import_react.useRef)("");
61
+ const [scanState, setScanState] = (0, import_react.useState)("init");
62
+ const [capturedUrl, setCapturedUrl] = (0, import_react.useState)("");
63
+ const [errorMsg, setErrorMsg] = (0, import_react.useState)("");
64
+ const [statusMsg, setStatusMsg] = (0, import_react.useState)("Loading OpenCV\u2026");
65
+ const [foundCount, setFoundCount] = (0, import_react.useState)(0);
66
+ const [qrValue, setQrValue] = (0, import_react.useState)("");
67
+ (0, import_react.useEffect)(() => {
68
+ return () => {
69
+ streamRef.current?.getTracks().forEach((t) => t.stop());
70
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
71
+ };
72
+ }, []);
73
+ const capturedUrlRef = (0, import_react.useRef)("");
74
+ (0, import_react.useEffect)(() => {
75
+ capturedUrlRef.current = capturedUrl;
76
+ }, [capturedUrl]);
77
+ (0, import_react.useEffect)(() => {
78
+ return () => {
79
+ if (capturedUrlRef.current) URL.revokeObjectURL(capturedUrlRef.current);
80
+ };
81
+ }, []);
82
+ const attachStream = (0, import_react.useCallback)(async (stream) => {
83
+ const video = videoRef.current;
84
+ video.srcObject = stream;
85
+ await video.play();
86
+ }, []);
87
+ (0, import_react.useEffect)(() => {
88
+ let cancelled = false;
89
+ (async () => {
90
+ try {
91
+ setStatusMsg("Loading OpenCV\u2026");
92
+ await (0, import_cv_worker.initWorker)();
93
+ if (cancelled) return;
94
+ setStatusMsg("Opening camera\u2026");
95
+ const stream = await openCamera();
96
+ if (cancelled) {
97
+ stream.getTracks().forEach((t) => t.stop());
98
+ return;
99
+ }
100
+ streamRef.current = stream;
101
+ await attachStream(stream);
102
+ setScanState("scanning");
103
+ } catch (e) {
104
+ if (!cancelled) {
105
+ setErrorMsg(e instanceof Error ? e.message : "Camera or OpenCV error");
106
+ setScanState("error");
107
+ }
108
+ }
109
+ })();
110
+ return () => {
111
+ cancelled = true;
112
+ };
113
+ }, [attachStream]);
114
+ const drawOverlay = (0, import_react.useCallback)((found, result) => {
115
+ const canvas = overlayRef.current;
116
+ const video = videoRef.current;
117
+ if (!canvas || !video) return;
118
+ const ctx = canvas.getContext("2d");
119
+ const rect = video.getBoundingClientRect();
120
+ if (rect.width !== overlayDimRef.current.w || rect.height !== overlayDimRef.current.h) {
121
+ canvas.width = rect.width;
122
+ canvas.height = rect.height;
123
+ overlayDimRef.current = { w: rect.width, h: rect.height };
124
+ }
125
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
126
+ const allFound = found === 4;
127
+ const color = allFound ? "#00ff88" : "#ff4444";
128
+ const label = allFound ? "\u2713 All markers found" : `${found} / 4 markers found`;
129
+ const bw = canvas.width, bh = canvas.height;
130
+ const len = Math.min(bw, bh) * BRACKET_LEN_RATIO;
131
+ ctx.strokeStyle = color;
132
+ ctx.lineWidth = 3;
133
+ const drawBracket = (x, y, dx, dy) => {
134
+ ctx.beginPath();
135
+ ctx.moveTo(x + dx * len, y);
136
+ ctx.lineTo(x, y);
137
+ ctx.lineTo(x, y + dy * len);
138
+ ctx.stroke();
139
+ };
140
+ drawBracket(BRACKET_INSET, BRACKET_INSET, 1, 1);
141
+ drawBracket(bw - BRACKET_INSET, BRACKET_INSET, -1, 1);
142
+ drawBracket(BRACKET_INSET, bh - BRACKET_INSET, 1, -1);
143
+ drawBracket(bw - BRACKET_INSET, bh - BRACKET_INSET, -1, -1);
144
+ if (result) {
145
+ const sx = bw / result.imageWidth;
146
+ const sy = bh / result.imageHeight;
147
+ const markerColors = { 0: "#00e5ff", 1: "#ffe100", 2: "#ff4f00" };
148
+ const markerLabels = { 0: "TL", 1: "BR", 2: "BL" };
149
+ for (const m of result.arucoMarkers) {
150
+ const mc = markerColors[m.id] ?? "#ffffff";
151
+ ctx.strokeStyle = mc;
152
+ ctx.lineWidth = 2;
153
+ ctx.beginPath();
154
+ m.corners.forEach((p, i) => {
155
+ if (i === 0) ctx.moveTo(p.x * sx, p.y * sy);
156
+ else ctx.lineTo(p.x * sx, p.y * sy);
157
+ });
158
+ ctx.closePath();
159
+ ctx.stroke();
160
+ ctx.fillStyle = mc;
161
+ ctx.font = "bold 12px sans-serif";
162
+ ctx.textAlign = "left";
163
+ ctx.fillText(markerLabels[m.id] ?? `A${m.id}`, m.center.x * sx + 4, m.center.y * sy - 4);
164
+ }
165
+ if (result.qrCode.found) {
166
+ ctx.fillStyle = "#00ff88";
167
+ ctx.font = "bold 12px sans-serif";
168
+ ctx.textAlign = "left";
169
+ ctx.fillText("QR \u2713", 4, 16);
170
+ }
171
+ }
172
+ ctx.fillStyle = "rgba(0,0,0,0.55)";
173
+ ctx.fillRect(0, bh - STATUS_BAR_H, bw, STATUS_BAR_H);
174
+ ctx.fillStyle = color;
175
+ ctx.font = `bold ${Math.round(bh * 0.022 + 11)}px sans-serif`;
176
+ ctx.textAlign = "center";
177
+ ctx.fillText(label, bw / 2, bh - 12);
178
+ }, []);
179
+ const captureBestFrame = (0, import_react.useCallback)(() => {
180
+ const canvas = captureCanvasRef.current;
181
+ streamRef.current?.getTracks().forEach((t) => t.stop());
182
+ streamRef.current = null;
183
+ canvas.toBlob((blob) => {
184
+ if (!blob) return;
185
+ if (capturedUrlRef.current) URL.revokeObjectURL(capturedUrlRef.current);
186
+ setCapturedUrl(URL.createObjectURL(blob));
187
+ }, "image/png");
188
+ setScanState("captured");
189
+ setQrValue(qrValueRef.current);
190
+ }, []);
191
+ const startDetection = (0, import_react.useCallback)(() => {
192
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
193
+ detectingRef.current = false;
194
+ validCountRef.current = 0;
195
+ const loop = async () => {
196
+ const video = videoRef.current;
197
+ if (!video || video.readyState < 2 || detectingRef.current) {
198
+ rafRef.current = requestAnimationFrame(loop);
199
+ return;
200
+ }
201
+ detectingRef.current = true;
202
+ try {
203
+ const { videoWidth: vw, videoHeight: vh } = video;
204
+ const captureCanvas = captureCanvasRef.current;
205
+ if (captureCanvas.width !== vw || captureCanvas.height !== vh) {
206
+ captureCanvas.width = vw;
207
+ captureCanvas.height = vh;
208
+ }
209
+ captureCanvas.getContext("2d").drawImage(video, 0, 0);
210
+ const scale = Math.min(1, SCAN_DIM / Math.max(vw, vh));
211
+ const w = Math.round(vw * scale), h = Math.round(vh * scale);
212
+ if (!offscreenRef.current || offscreenDimRef.current.w !== w || offscreenDimRef.current.h !== h) {
213
+ offscreenRef.current = new OffscreenCanvas(w, h);
214
+ offscreenDimRef.current = { w, h };
215
+ }
216
+ const ctx = offscreenRef.current.getContext("2d", { willReadFrequently: true });
217
+ ctx.drawImage(video, 0, 0, w, h);
218
+ const result = await (0, import_cv_worker.detectInImageData)(ctx.getImageData(0, 0, w, h));
219
+ if (barcodeDetectorRef.current === void 0) {
220
+ const BD = window.BarcodeDetector;
221
+ barcodeDetectorRef.current = BD ? new BD({ formats: ["qr_code"] }) : null;
222
+ }
223
+ const hasAllAruco = [0, 1, 2].every((id) => result.arucoMarkers.some((m) => m.id === id));
224
+ if (hasAllAruco) {
225
+ if (qrValueRef.current) {
226
+ result.qrCode = { found: true };
227
+ } else {
228
+ const qrCrop = (0, import_core.estimateQRCropBox)(result.arucoMarkers, w, h, vw, vh);
229
+ if (qrCrop) {
230
+ const cropScale = Math.min(1, QR_REALTIME_CROP_PX / Math.max(qrCrop.w, qrCrop.h));
231
+ const qrW = Math.max(1, Math.round(qrCrop.w * cropScale));
232
+ const qrH = Math.max(1, Math.round(qrCrop.h * cropScale));
233
+ if (!qrCropCanvasRef.current || qrCropCanvasRef.current.width !== qrW || qrCropCanvasRef.current.height !== qrH) {
234
+ qrCropCanvasRef.current = new OffscreenCanvas(qrW, qrH);
235
+ }
236
+ const qrCtx = qrCropCanvasRef.current.getContext("2d", { willReadFrequently: true });
237
+ qrCtx.drawImage(captureCanvas, qrCrop.x0, qrCrop.y0, qrCrop.w, qrCrop.h, 0, 0, qrW, qrH);
238
+ const qrDet = await (0, import_core.detectQRInCrop)(qrCropCanvasRef.current, { barcodeDetector: barcodeDetectorRef.current });
239
+ if (qrDet.found) {
240
+ result.qrCode = { found: true };
241
+ if (qrDet.rawValue) qrValueRef.current = qrDet.rawValue;
242
+ }
243
+ }
244
+ }
245
+ }
246
+ const found = countFound(result);
247
+ setFoundCount(found);
248
+ drawOverlay(found, result);
249
+ if (found === 4) {
250
+ validCountRef.current += 1;
251
+ if (validCountRef.current >= VALID_FRAMES_REQUIRED) {
252
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
253
+ rafRef.current = null;
254
+ captureBestFrame();
255
+ return;
256
+ }
257
+ } else {
258
+ validCountRef.current = 0;
259
+ }
260
+ } catch (e) {
261
+ console.warn("[useScanner] detection error", e);
262
+ validCountRef.current = 0;
263
+ } finally {
264
+ detectingRef.current = false;
265
+ }
266
+ rafRef.current = requestAnimationFrame(loop);
267
+ };
268
+ rafRef.current = requestAnimationFrame(loop);
269
+ }, [drawOverlay, captureBestFrame]);
270
+ (0, import_react.useEffect)(() => {
271
+ if (scanState === "scanning") startDetection();
272
+ return () => {
273
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
274
+ };
275
+ }, [scanState, startDetection]);
276
+ const restart = (0, import_react.useCallback)(async () => {
277
+ if (rafRef.current) {
278
+ cancelAnimationFrame(rafRef.current);
279
+ rafRef.current = null;
280
+ }
281
+ streamRef.current?.getTracks().forEach((t) => t.stop());
282
+ streamRef.current = null;
283
+ validCountRef.current = 0;
284
+ if (capturedUrlRef.current) {
285
+ URL.revokeObjectURL(capturedUrlRef.current);
286
+ setCapturedUrl("");
287
+ }
288
+ setFoundCount(0);
289
+ setQrValue("");
290
+ qrValueRef.current = "";
291
+ setErrorMsg("");
292
+ setStatusMsg("Opening camera\u2026");
293
+ setScanState("init");
294
+ try {
295
+ const stream = await openCamera();
296
+ streamRef.current = stream;
297
+ await attachStream(stream);
298
+ setScanState("scanning");
299
+ } catch (e) {
300
+ setErrorMsg(e instanceof Error ? e.message : "Camera error");
301
+ setScanState("error");
302
+ }
303
+ }, [attachStream]);
304
+ return {
305
+ videoRef,
306
+ overlayRef,
307
+ captureCanvasRef,
308
+ scanState,
309
+ capturedUrl,
310
+ errorMsg,
311
+ statusMsg,
312
+ foundCount,
313
+ qrValue,
314
+ restart
315
+ };
316
+ }
317
+ // Annotate the CommonJS export names for ESM import in node:
318
+ 0 && (module.exports = {
319
+ useScanner
320
+ });
321
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/useScanner.ts"],"sourcesContent":["export { useScanner } from './useScanner';\nexport type { UseScannerReturn, ScanState } from './useScanner';\n","import { useCallback, useEffect, useRef, useState } from 'react';\nimport type { RefObject } from 'react';\nimport { initWorker, detectInImageData } from '@dcg-overseas/cv-worker';\nimport { estimateQRCropBox, detectQRInCrop } from '@dcg-overseas/core';\nimport type { DetectionResult } from '@dcg-overseas/types';\n\n// ── 检测常量 ──────────────────────────────────────────────────────────────────\nconst SCAN_DIM = 720; // 检测缩略图长边(px)\nconst VALID_FRAMES_REQUIRED = 2; // 连续有效帧数阈值\nconst QR_REALTIME_CROP_PX = 600; // QR 裁切图上限(px),只缩小不放大\n\n// ── Overlay 绘制常量 ──────────────────────────────────────────────────────────\nconst BRACKET_INSET = 12; // 取景框括号内缩距离(px)\nconst BRACKET_LEN_RATIO = 0.08; // 括号边长占短边比例\nconst STATUS_BAR_H = 40; // 底部状态栏高度(px)\n\nexport type ScanState = 'init' | 'scanning' | 'captured' | 'error';\n\nexport interface UseScannerReturn {\n // ── DOM refs(直接绑定到 JSX 元素)──────────────────────────────────────────\n videoRef: RefObject<HTMLVideoElement | null>;\n overlayRef: RefObject<HTMLCanvasElement | null>;\n captureCanvasRef: RefObject<HTMLCanvasElement | null>;\n // ── 状态 ─────────────────────────────────────────────────────────────────────\n scanState: ScanState;\n capturedUrl: string;\n errorMsg: string;\n statusMsg: string;\n foundCount: number;\n qrValue: string;\n // ── 动作 ─────────────────────────────────────────────────────────────────────\n restart: () => Promise<void>;\n}\n\nasync function openCamera(): Promise<MediaStream> {\n return navigator.mediaDevices.getUserMedia({\n video: { width: { ideal: 3840 }, height: { ideal: 2160 }, facingMode: { ideal: 'environment' } },\n audio: false,\n });\n}\n\nfunction countFound(result: DetectionResult): number {\n const aruco = [0, 1, 2].filter(id => result.arucoMarkers.some(m => m.id === id)).length;\n return aruco + (result.qrCode.found ? 1 : 0);\n}\n\nexport function useScanner(): UseScannerReturn {\n const videoRef = useRef<HTMLVideoElement>(null);\n const overlayRef = useRef<HTMLCanvasElement>(null);\n const captureCanvasRef = useRef<HTMLCanvasElement>(null);\n const streamRef = useRef<MediaStream | null>(null);\n const rafRef = useRef<number | null>(null);\n const detectingRef = useRef(false);\n const validCountRef = useRef(0);\n const overlayDimRef = useRef({ w: 0, h: 0 });\n const offscreenRef = useRef<OffscreenCanvas | null>(null);\n const offscreenDimRef = useRef({ w: 0, h: 0 });\n const qrCropCanvasRef = useRef<OffscreenCanvas | null>(null);\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const barcodeDetectorRef = useRef<any>(undefined); // undefined=未检测, null=不支持, 否则为实例\n // 检测循环中暂存 QR 文本,避免 setState 干扰帧率,捕获时一次性写入 state\n const qrValueRef = useRef('');\n\n const [scanState, setScanState] = useState<ScanState>('init');\n const [capturedUrl, setCapturedUrl] = useState('');\n const [errorMsg, setErrorMsg] = useState('');\n const [statusMsg, setStatusMsg] = useState('Loading OpenCV…');\n const [foundCount, setFoundCount] = useState(0);\n const [qrValue, setQrValue] = useState('');\n\n // ── unmount cleanup ───────────────────────────────────────────────────────\n useEffect(() => {\n return () => {\n streamRef.current?.getTracks().forEach(t => t.stop());\n if (rafRef.current) cancelAnimationFrame(rafRef.current);\n };\n }, []);\n\n const capturedUrlRef = useRef('');\n useEffect(() => { capturedUrlRef.current = capturedUrl; }, [capturedUrl]);\n useEffect(() => {\n return () => { if (capturedUrlRef.current) URL.revokeObjectURL(capturedUrlRef.current); };\n }, []);\n\n const attachStream = useCallback(async (stream: MediaStream) => {\n const video = videoRef.current!;\n video.srcObject = stream;\n await video.play();\n }, []);\n\n // ── initial boot ──────────────────────────────────────────────────────────\n useEffect(() => {\n let cancelled = false;\n (async () => {\n try {\n setStatusMsg('Loading OpenCV…');\n await initWorker();\n if (cancelled) return;\n setStatusMsg('Opening camera…');\n const stream = await openCamera();\n if (cancelled) { stream.getTracks().forEach(t => t.stop()); return; }\n streamRef.current = stream;\n await attachStream(stream);\n setScanState('scanning');\n } catch (e: unknown) {\n if (!cancelled) {\n setErrorMsg(e instanceof Error ? e.message : 'Camera or OpenCV error');\n setScanState('error');\n }\n }\n })();\n return () => { cancelled = true; };\n }, [attachStream]);\n\n // ── overlay drawing ───────────────────────────────────────────────────────\n const drawOverlay = useCallback((found: number, result?: DetectionResult) => {\n const canvas = overlayRef.current;\n const video = videoRef.current;\n if (!canvas || !video) return;\n const ctx = canvas.getContext('2d')!;\n const rect = video.getBoundingClientRect();\n\n if (rect.width !== overlayDimRef.current.w || rect.height !== overlayDimRef.current.h) {\n canvas.width = rect.width;\n canvas.height = rect.height;\n overlayDimRef.current = { w: rect.width, h: rect.height };\n }\n ctx.clearRect(0, 0, canvas.width, canvas.height);\n\n const allFound = found === 4;\n const color = allFound ? '#00ff88' : '#ff4444';\n const label = allFound ? '✓ All markers found' : `${found} / 4 markers found`;\n const bw = canvas.width, bh = canvas.height;\n const len = Math.min(bw, bh) * BRACKET_LEN_RATIO;\n\n ctx.strokeStyle = color;\n ctx.lineWidth = 3;\n const drawBracket = (x: number, y: number, dx: number, dy: number) => {\n ctx.beginPath();\n ctx.moveTo(x + dx * len, y); ctx.lineTo(x, y); ctx.lineTo(x, y + dy * len);\n ctx.stroke();\n };\n drawBracket(BRACKET_INSET, BRACKET_INSET, 1, 1);\n drawBracket(bw - BRACKET_INSET, BRACKET_INSET, -1, 1);\n drawBracket(BRACKET_INSET, bh - BRACKET_INSET, 1, -1);\n drawBracket(bw - BRACKET_INSET, bh - BRACKET_INSET,-1, -1);\n\n if (result) {\n const sx = bw / result.imageWidth;\n const sy = bh / result.imageHeight;\n const markerColors: Record<number, string> = { 0: '#00e5ff', 1: '#ffe100', 2: '#ff4f00' };\n const markerLabels: Record<number, string> = { 0: 'TL', 1: 'BR', 2: 'BL' };\n\n for (const m of result.arucoMarkers) {\n const mc = markerColors[m.id] ?? '#ffffff';\n ctx.strokeStyle = mc; ctx.lineWidth = 2;\n ctx.beginPath();\n m.corners.forEach((p, i) => {\n if (i === 0) ctx.moveTo(p.x * sx, p.y * sy); else ctx.lineTo(p.x * sx, p.y * sy);\n });\n ctx.closePath(); ctx.stroke();\n ctx.fillStyle = mc; ctx.font = 'bold 12px sans-serif'; ctx.textAlign = 'left';\n ctx.fillText(markerLabels[m.id] ?? `A${m.id}`, m.center.x * sx + 4, m.center.y * sy - 4);\n }\n if (result.qrCode.found) {\n ctx.fillStyle = '#00ff88'; ctx.font = 'bold 12px sans-serif'; ctx.textAlign = 'left';\n ctx.fillText('QR ✓', 4, 16);\n }\n }\n\n ctx.fillStyle = 'rgba(0,0,0,0.55)';\n ctx.fillRect(0, bh - STATUS_BAR_H, bw, STATUS_BAR_H);\n ctx.fillStyle = color;\n ctx.font = `bold ${Math.round(bh * 0.022 + 11)}px sans-serif`;\n ctx.textAlign = 'center';\n ctx.fillText(label, bw / 2, bh - 12);\n }, []);\n\n // ── capture ───────────────────────────────────────────────────────────────\n // captureCanvasRef 在检测循环顶部已同步冻结当前帧,直接转 blob 即可\n const captureBestFrame = useCallback(() => {\n const canvas = captureCanvasRef.current!;\n streamRef.current?.getTracks().forEach(t => t.stop());\n streamRef.current = null;\n canvas.toBlob(blob => {\n if (!blob) return;\n if (capturedUrlRef.current) URL.revokeObjectURL(capturedUrlRef.current);\n setCapturedUrl(URL.createObjectURL(blob));\n }, 'image/png');\n setScanState('captured');\n setQrValue(qrValueRef.current);\n }, []);\n\n // ── detection loop(rAF)────────────────────────────────────────────────\n const startDetection = useCallback(() => {\n if (rafRef.current) cancelAnimationFrame(rafRef.current);\n detectingRef.current = false;\n validCountRef.current = 0;\n\n const loop = async () => {\n const video = videoRef.current;\n if (!video || video.readyState < 2 || detectingRef.current) {\n rafRef.current = requestAnimationFrame(loop);\n return;\n }\n detectingRef.current = true;\n try {\n const { videoWidth: vw, videoHeight: vh } = video;\n\n // 同步冻结当前帧到 captureCanvas(await 前)\n const captureCanvas = captureCanvasRef.current!;\n if (captureCanvas.width !== vw || captureCanvas.height !== vh) {\n captureCanvas.width = vw; captureCanvas.height = vh;\n }\n captureCanvas.getContext('2d')!.drawImage(video, 0, 0);\n\n const scale = Math.min(1, SCAN_DIM / Math.max(vw, vh));\n const w = Math.round(vw * scale), h = Math.round(vh * scale);\n if (!offscreenRef.current || offscreenDimRef.current.w !== w || offscreenDimRef.current.h !== h) {\n offscreenRef.current = new OffscreenCanvas(w, h);\n offscreenDimRef.current = { w, h };\n }\n const ctx = offscreenRef.current.getContext('2d', { willReadFrequently: true }) as OffscreenCanvasRenderingContext2D;\n ctx.drawImage(video, 0, 0, w, h);\n const result = await detectInImageData(ctx.getImageData(0, 0, w, h));\n\n // ── QR 检测 ───────────────────────────────────────────────────────\n if (barcodeDetectorRef.current === undefined) {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const BD = (window as any).BarcodeDetector;\n barcodeDetectorRef.current = BD ? new BD({ formats: ['qr_code'] }) : null;\n }\n const hasAllAruco = [0, 1, 2].every(id => result.arucoMarkers.some(m => m.id === id));\n if (hasAllAruco) {\n if (qrValueRef.current) {\n result.qrCode = { found: true }; // 已解码,复用缓存\n } else {\n const qrCrop = estimateQRCropBox(result.arucoMarkers, w, h, vw, vh);\n if (qrCrop) {\n const cropScale = Math.min(1, QR_REALTIME_CROP_PX / Math.max(qrCrop.w, qrCrop.h));\n const qrW = Math.max(1, Math.round(qrCrop.w * cropScale));\n const qrH = Math.max(1, Math.round(qrCrop.h * cropScale));\n if (!qrCropCanvasRef.current ||\n qrCropCanvasRef.current.width !== qrW || qrCropCanvasRef.current.height !== qrH) {\n qrCropCanvasRef.current = new OffscreenCanvas(qrW, qrH);\n }\n const qrCtx = qrCropCanvasRef.current.getContext('2d', { willReadFrequently: true }) as OffscreenCanvasRenderingContext2D;\n qrCtx.drawImage(captureCanvas, qrCrop.x0, qrCrop.y0, qrCrop.w, qrCrop.h, 0, 0, qrW, qrH);\n const qrDet = await detectQRInCrop(qrCropCanvasRef.current!, { barcodeDetector: barcodeDetectorRef.current });\n if (qrDet.found) {\n result.qrCode = { found: true };\n if (qrDet.rawValue) qrValueRef.current = qrDet.rawValue;\n }\n }\n }\n }\n\n const found = countFound(result);\n setFoundCount(found);\n drawOverlay(found, result);\n\n if (found === 4) {\n validCountRef.current += 1;\n if (validCountRef.current >= VALID_FRAMES_REQUIRED) {\n if (rafRef.current) cancelAnimationFrame(rafRef.current);\n rafRef.current = null;\n captureBestFrame();\n return;\n }\n } else {\n validCountRef.current = 0;\n }\n } catch (e) {\n console.warn('[useScanner] detection error', e);\n validCountRef.current = 0;\n } finally {\n detectingRef.current = false;\n }\n rafRef.current = requestAnimationFrame(loop);\n };\n rafRef.current = requestAnimationFrame(loop);\n }, [drawOverlay, captureBestFrame]);\n\n useEffect(() => {\n if (scanState === 'scanning') startDetection();\n return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };\n }, [scanState, startDetection]);\n\n // ── restart ───────────────────────────────────────────────────────────────\n const restart = useCallback(async () => {\n if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; }\n streamRef.current?.getTracks().forEach(t => t.stop());\n streamRef.current = null;\n validCountRef.current = 0;\n if (capturedUrlRef.current) { URL.revokeObjectURL(capturedUrlRef.current); setCapturedUrl(''); }\n setFoundCount(0); setQrValue(''); qrValueRef.current = '';\n setErrorMsg(''); setStatusMsg('Opening camera…'); setScanState('init');\n try {\n const stream = await openCamera();\n streamRef.current = stream;\n await attachStream(stream);\n setScanState('scanning');\n } catch (e) {\n setErrorMsg(e instanceof Error ? e.message : 'Camera error');\n setScanState('error');\n }\n }, [attachStream]);\n\n return {\n videoRef, overlayRef, captureCanvasRef,\n scanState, capturedUrl, errorMsg, statusMsg, foundCount, qrValue,\n restart,\n };\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,18 @@
1
+ import { RefObject } from 'react';
2
+
3
+ type ScanState = 'init' | 'scanning' | 'captured' | 'error';
4
+ interface UseScannerReturn {
5
+ videoRef: RefObject<HTMLVideoElement | null>;
6
+ overlayRef: RefObject<HTMLCanvasElement | null>;
7
+ captureCanvasRef: RefObject<HTMLCanvasElement | null>;
8
+ scanState: ScanState;
9
+ capturedUrl: string;
10
+ errorMsg: string;
11
+ statusMsg: string;
12
+ foundCount: number;
13
+ qrValue: string;
14
+ restart: () => Promise<void>;
15
+ }
16
+ declare function useScanner(): UseScannerReturn;
17
+
18
+ export { type ScanState, type UseScannerReturn, useScanner };
@@ -0,0 +1,18 @@
1
+ import { RefObject } from 'react';
2
+
3
+ type ScanState = 'init' | 'scanning' | 'captured' | 'error';
4
+ interface UseScannerReturn {
5
+ videoRef: RefObject<HTMLVideoElement | null>;
6
+ overlayRef: RefObject<HTMLCanvasElement | null>;
7
+ captureCanvasRef: RefObject<HTMLCanvasElement | null>;
8
+ scanState: ScanState;
9
+ capturedUrl: string;
10
+ errorMsg: string;
11
+ statusMsg: string;
12
+ foundCount: number;
13
+ qrValue: string;
14
+ restart: () => Promise<void>;
15
+ }
16
+ declare function useScanner(): UseScannerReturn;
17
+
18
+ export { type ScanState, type UseScannerReturn, useScanner };
package/dist/index.js ADDED
@@ -0,0 +1,294 @@
1
+ // src/useScanner.ts
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { initWorker, detectInImageData } from "@dcg-overseas/cv-worker";
4
+ import { estimateQRCropBox, detectQRInCrop } from "@dcg-overseas/core";
5
+ var SCAN_DIM = 720;
6
+ var VALID_FRAMES_REQUIRED = 2;
7
+ var QR_REALTIME_CROP_PX = 600;
8
+ var BRACKET_INSET = 12;
9
+ var BRACKET_LEN_RATIO = 0.08;
10
+ var STATUS_BAR_H = 40;
11
+ async function openCamera() {
12
+ return navigator.mediaDevices.getUserMedia({
13
+ video: { width: { ideal: 3840 }, height: { ideal: 2160 }, facingMode: { ideal: "environment" } },
14
+ audio: false
15
+ });
16
+ }
17
+ function countFound(result) {
18
+ const aruco = [0, 1, 2].filter((id) => result.arucoMarkers.some((m) => m.id === id)).length;
19
+ return aruco + (result.qrCode.found ? 1 : 0);
20
+ }
21
+ function useScanner() {
22
+ const videoRef = useRef(null);
23
+ const overlayRef = useRef(null);
24
+ const captureCanvasRef = useRef(null);
25
+ const streamRef = useRef(null);
26
+ const rafRef = useRef(null);
27
+ const detectingRef = useRef(false);
28
+ const validCountRef = useRef(0);
29
+ const overlayDimRef = useRef({ w: 0, h: 0 });
30
+ const offscreenRef = useRef(null);
31
+ const offscreenDimRef = useRef({ w: 0, h: 0 });
32
+ const qrCropCanvasRef = useRef(null);
33
+ const barcodeDetectorRef = useRef(void 0);
34
+ const qrValueRef = useRef("");
35
+ const [scanState, setScanState] = useState("init");
36
+ const [capturedUrl, setCapturedUrl] = useState("");
37
+ const [errorMsg, setErrorMsg] = useState("");
38
+ const [statusMsg, setStatusMsg] = useState("Loading OpenCV\u2026");
39
+ const [foundCount, setFoundCount] = useState(0);
40
+ const [qrValue, setQrValue] = useState("");
41
+ useEffect(() => {
42
+ return () => {
43
+ streamRef.current?.getTracks().forEach((t) => t.stop());
44
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
45
+ };
46
+ }, []);
47
+ const capturedUrlRef = useRef("");
48
+ useEffect(() => {
49
+ capturedUrlRef.current = capturedUrl;
50
+ }, [capturedUrl]);
51
+ useEffect(() => {
52
+ return () => {
53
+ if (capturedUrlRef.current) URL.revokeObjectURL(capturedUrlRef.current);
54
+ };
55
+ }, []);
56
+ const attachStream = useCallback(async (stream) => {
57
+ const video = videoRef.current;
58
+ video.srcObject = stream;
59
+ await video.play();
60
+ }, []);
61
+ useEffect(() => {
62
+ let cancelled = false;
63
+ (async () => {
64
+ try {
65
+ setStatusMsg("Loading OpenCV\u2026");
66
+ await initWorker();
67
+ if (cancelled) return;
68
+ setStatusMsg("Opening camera\u2026");
69
+ const stream = await openCamera();
70
+ if (cancelled) {
71
+ stream.getTracks().forEach((t) => t.stop());
72
+ return;
73
+ }
74
+ streamRef.current = stream;
75
+ await attachStream(stream);
76
+ setScanState("scanning");
77
+ } catch (e) {
78
+ if (!cancelled) {
79
+ setErrorMsg(e instanceof Error ? e.message : "Camera or OpenCV error");
80
+ setScanState("error");
81
+ }
82
+ }
83
+ })();
84
+ return () => {
85
+ cancelled = true;
86
+ };
87
+ }, [attachStream]);
88
+ const drawOverlay = useCallback((found, result) => {
89
+ const canvas = overlayRef.current;
90
+ const video = videoRef.current;
91
+ if (!canvas || !video) return;
92
+ const ctx = canvas.getContext("2d");
93
+ const rect = video.getBoundingClientRect();
94
+ if (rect.width !== overlayDimRef.current.w || rect.height !== overlayDimRef.current.h) {
95
+ canvas.width = rect.width;
96
+ canvas.height = rect.height;
97
+ overlayDimRef.current = { w: rect.width, h: rect.height };
98
+ }
99
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
100
+ const allFound = found === 4;
101
+ const color = allFound ? "#00ff88" : "#ff4444";
102
+ const label = allFound ? "\u2713 All markers found" : `${found} / 4 markers found`;
103
+ const bw = canvas.width, bh = canvas.height;
104
+ const len = Math.min(bw, bh) * BRACKET_LEN_RATIO;
105
+ ctx.strokeStyle = color;
106
+ ctx.lineWidth = 3;
107
+ const drawBracket = (x, y, dx, dy) => {
108
+ ctx.beginPath();
109
+ ctx.moveTo(x + dx * len, y);
110
+ ctx.lineTo(x, y);
111
+ ctx.lineTo(x, y + dy * len);
112
+ ctx.stroke();
113
+ };
114
+ drawBracket(BRACKET_INSET, BRACKET_INSET, 1, 1);
115
+ drawBracket(bw - BRACKET_INSET, BRACKET_INSET, -1, 1);
116
+ drawBracket(BRACKET_INSET, bh - BRACKET_INSET, 1, -1);
117
+ drawBracket(bw - BRACKET_INSET, bh - BRACKET_INSET, -1, -1);
118
+ if (result) {
119
+ const sx = bw / result.imageWidth;
120
+ const sy = bh / result.imageHeight;
121
+ const markerColors = { 0: "#00e5ff", 1: "#ffe100", 2: "#ff4f00" };
122
+ const markerLabels = { 0: "TL", 1: "BR", 2: "BL" };
123
+ for (const m of result.arucoMarkers) {
124
+ const mc = markerColors[m.id] ?? "#ffffff";
125
+ ctx.strokeStyle = mc;
126
+ ctx.lineWidth = 2;
127
+ ctx.beginPath();
128
+ m.corners.forEach((p, i) => {
129
+ if (i === 0) ctx.moveTo(p.x * sx, p.y * sy);
130
+ else ctx.lineTo(p.x * sx, p.y * sy);
131
+ });
132
+ ctx.closePath();
133
+ ctx.stroke();
134
+ ctx.fillStyle = mc;
135
+ ctx.font = "bold 12px sans-serif";
136
+ ctx.textAlign = "left";
137
+ ctx.fillText(markerLabels[m.id] ?? `A${m.id}`, m.center.x * sx + 4, m.center.y * sy - 4);
138
+ }
139
+ if (result.qrCode.found) {
140
+ ctx.fillStyle = "#00ff88";
141
+ ctx.font = "bold 12px sans-serif";
142
+ ctx.textAlign = "left";
143
+ ctx.fillText("QR \u2713", 4, 16);
144
+ }
145
+ }
146
+ ctx.fillStyle = "rgba(0,0,0,0.55)";
147
+ ctx.fillRect(0, bh - STATUS_BAR_H, bw, STATUS_BAR_H);
148
+ ctx.fillStyle = color;
149
+ ctx.font = `bold ${Math.round(bh * 0.022 + 11)}px sans-serif`;
150
+ ctx.textAlign = "center";
151
+ ctx.fillText(label, bw / 2, bh - 12);
152
+ }, []);
153
+ const captureBestFrame = useCallback(() => {
154
+ const canvas = captureCanvasRef.current;
155
+ streamRef.current?.getTracks().forEach((t) => t.stop());
156
+ streamRef.current = null;
157
+ canvas.toBlob((blob) => {
158
+ if (!blob) return;
159
+ if (capturedUrlRef.current) URL.revokeObjectURL(capturedUrlRef.current);
160
+ setCapturedUrl(URL.createObjectURL(blob));
161
+ }, "image/png");
162
+ setScanState("captured");
163
+ setQrValue(qrValueRef.current);
164
+ }, []);
165
+ const startDetection = useCallback(() => {
166
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
167
+ detectingRef.current = false;
168
+ validCountRef.current = 0;
169
+ const loop = async () => {
170
+ const video = videoRef.current;
171
+ if (!video || video.readyState < 2 || detectingRef.current) {
172
+ rafRef.current = requestAnimationFrame(loop);
173
+ return;
174
+ }
175
+ detectingRef.current = true;
176
+ try {
177
+ const { videoWidth: vw, videoHeight: vh } = video;
178
+ const captureCanvas = captureCanvasRef.current;
179
+ if (captureCanvas.width !== vw || captureCanvas.height !== vh) {
180
+ captureCanvas.width = vw;
181
+ captureCanvas.height = vh;
182
+ }
183
+ captureCanvas.getContext("2d").drawImage(video, 0, 0);
184
+ const scale = Math.min(1, SCAN_DIM / Math.max(vw, vh));
185
+ const w = Math.round(vw * scale), h = Math.round(vh * scale);
186
+ if (!offscreenRef.current || offscreenDimRef.current.w !== w || offscreenDimRef.current.h !== h) {
187
+ offscreenRef.current = new OffscreenCanvas(w, h);
188
+ offscreenDimRef.current = { w, h };
189
+ }
190
+ const ctx = offscreenRef.current.getContext("2d", { willReadFrequently: true });
191
+ ctx.drawImage(video, 0, 0, w, h);
192
+ const result = await detectInImageData(ctx.getImageData(0, 0, w, h));
193
+ if (barcodeDetectorRef.current === void 0) {
194
+ const BD = window.BarcodeDetector;
195
+ barcodeDetectorRef.current = BD ? new BD({ formats: ["qr_code"] }) : null;
196
+ }
197
+ const hasAllAruco = [0, 1, 2].every((id) => result.arucoMarkers.some((m) => m.id === id));
198
+ if (hasAllAruco) {
199
+ if (qrValueRef.current) {
200
+ result.qrCode = { found: true };
201
+ } else {
202
+ const qrCrop = estimateQRCropBox(result.arucoMarkers, w, h, vw, vh);
203
+ if (qrCrop) {
204
+ const cropScale = Math.min(1, QR_REALTIME_CROP_PX / Math.max(qrCrop.w, qrCrop.h));
205
+ const qrW = Math.max(1, Math.round(qrCrop.w * cropScale));
206
+ const qrH = Math.max(1, Math.round(qrCrop.h * cropScale));
207
+ if (!qrCropCanvasRef.current || qrCropCanvasRef.current.width !== qrW || qrCropCanvasRef.current.height !== qrH) {
208
+ qrCropCanvasRef.current = new OffscreenCanvas(qrW, qrH);
209
+ }
210
+ const qrCtx = qrCropCanvasRef.current.getContext("2d", { willReadFrequently: true });
211
+ qrCtx.drawImage(captureCanvas, qrCrop.x0, qrCrop.y0, qrCrop.w, qrCrop.h, 0, 0, qrW, qrH);
212
+ const qrDet = await detectQRInCrop(qrCropCanvasRef.current, { barcodeDetector: barcodeDetectorRef.current });
213
+ if (qrDet.found) {
214
+ result.qrCode = { found: true };
215
+ if (qrDet.rawValue) qrValueRef.current = qrDet.rawValue;
216
+ }
217
+ }
218
+ }
219
+ }
220
+ const found = countFound(result);
221
+ setFoundCount(found);
222
+ drawOverlay(found, result);
223
+ if (found === 4) {
224
+ validCountRef.current += 1;
225
+ if (validCountRef.current >= VALID_FRAMES_REQUIRED) {
226
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
227
+ rafRef.current = null;
228
+ captureBestFrame();
229
+ return;
230
+ }
231
+ } else {
232
+ validCountRef.current = 0;
233
+ }
234
+ } catch (e) {
235
+ console.warn("[useScanner] detection error", e);
236
+ validCountRef.current = 0;
237
+ } finally {
238
+ detectingRef.current = false;
239
+ }
240
+ rafRef.current = requestAnimationFrame(loop);
241
+ };
242
+ rafRef.current = requestAnimationFrame(loop);
243
+ }, [drawOverlay, captureBestFrame]);
244
+ useEffect(() => {
245
+ if (scanState === "scanning") startDetection();
246
+ return () => {
247
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
248
+ };
249
+ }, [scanState, startDetection]);
250
+ const restart = useCallback(async () => {
251
+ if (rafRef.current) {
252
+ cancelAnimationFrame(rafRef.current);
253
+ rafRef.current = null;
254
+ }
255
+ streamRef.current?.getTracks().forEach((t) => t.stop());
256
+ streamRef.current = null;
257
+ validCountRef.current = 0;
258
+ if (capturedUrlRef.current) {
259
+ URL.revokeObjectURL(capturedUrlRef.current);
260
+ setCapturedUrl("");
261
+ }
262
+ setFoundCount(0);
263
+ setQrValue("");
264
+ qrValueRef.current = "";
265
+ setErrorMsg("");
266
+ setStatusMsg("Opening camera\u2026");
267
+ setScanState("init");
268
+ try {
269
+ const stream = await openCamera();
270
+ streamRef.current = stream;
271
+ await attachStream(stream);
272
+ setScanState("scanning");
273
+ } catch (e) {
274
+ setErrorMsg(e instanceof Error ? e.message : "Camera error");
275
+ setScanState("error");
276
+ }
277
+ }, [attachStream]);
278
+ return {
279
+ videoRef,
280
+ overlayRef,
281
+ captureCanvasRef,
282
+ scanState,
283
+ capturedUrl,
284
+ errorMsg,
285
+ statusMsg,
286
+ foundCount,
287
+ qrValue,
288
+ restart
289
+ };
290
+ }
291
+ export {
292
+ useScanner
293
+ };
294
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/useScanner.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\nimport type { RefObject } from 'react';\nimport { initWorker, detectInImageData } from '@dcg-overseas/cv-worker';\nimport { estimateQRCropBox, detectQRInCrop } from '@dcg-overseas/core';\nimport type { DetectionResult } from '@dcg-overseas/types';\n\n// ── 检测常量 ──────────────────────────────────────────────────────────────────\nconst SCAN_DIM = 720; // 检测缩略图长边(px)\nconst VALID_FRAMES_REQUIRED = 2; // 连续有效帧数阈值\nconst QR_REALTIME_CROP_PX = 600; // QR 裁切图上限(px),只缩小不放大\n\n// ── Overlay 绘制常量 ──────────────────────────────────────────────────────────\nconst BRACKET_INSET = 12; // 取景框括号内缩距离(px)\nconst BRACKET_LEN_RATIO = 0.08; // 括号边长占短边比例\nconst STATUS_BAR_H = 40; // 底部状态栏高度(px)\n\nexport type ScanState = 'init' | 'scanning' | 'captured' | 'error';\n\nexport interface UseScannerReturn {\n // ── DOM refs(直接绑定到 JSX 元素)──────────────────────────────────────────\n videoRef: RefObject<HTMLVideoElement | null>;\n overlayRef: RefObject<HTMLCanvasElement | null>;\n captureCanvasRef: RefObject<HTMLCanvasElement | null>;\n // ── 状态 ─────────────────────────────────────────────────────────────────────\n scanState: ScanState;\n capturedUrl: string;\n errorMsg: string;\n statusMsg: string;\n foundCount: number;\n qrValue: string;\n // ── 动作 ─────────────────────────────────────────────────────────────────────\n restart: () => Promise<void>;\n}\n\nasync function openCamera(): Promise<MediaStream> {\n return navigator.mediaDevices.getUserMedia({\n video: { width: { ideal: 3840 }, height: { ideal: 2160 }, facingMode: { ideal: 'environment' } },\n audio: false,\n });\n}\n\nfunction countFound(result: DetectionResult): number {\n const aruco = [0, 1, 2].filter(id => result.arucoMarkers.some(m => m.id === id)).length;\n return aruco + (result.qrCode.found ? 1 : 0);\n}\n\nexport function useScanner(): UseScannerReturn {\n const videoRef = useRef<HTMLVideoElement>(null);\n const overlayRef = useRef<HTMLCanvasElement>(null);\n const captureCanvasRef = useRef<HTMLCanvasElement>(null);\n const streamRef = useRef<MediaStream | null>(null);\n const rafRef = useRef<number | null>(null);\n const detectingRef = useRef(false);\n const validCountRef = useRef(0);\n const overlayDimRef = useRef({ w: 0, h: 0 });\n const offscreenRef = useRef<OffscreenCanvas | null>(null);\n const offscreenDimRef = useRef({ w: 0, h: 0 });\n const qrCropCanvasRef = useRef<OffscreenCanvas | null>(null);\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const barcodeDetectorRef = useRef<any>(undefined); // undefined=未检测, null=不支持, 否则为实例\n // 检测循环中暂存 QR 文本,避免 setState 干扰帧率,捕获时一次性写入 state\n const qrValueRef = useRef('');\n\n const [scanState, setScanState] = useState<ScanState>('init');\n const [capturedUrl, setCapturedUrl] = useState('');\n const [errorMsg, setErrorMsg] = useState('');\n const [statusMsg, setStatusMsg] = useState('Loading OpenCV…');\n const [foundCount, setFoundCount] = useState(0);\n const [qrValue, setQrValue] = useState('');\n\n // ── unmount cleanup ───────────────────────────────────────────────────────\n useEffect(() => {\n return () => {\n streamRef.current?.getTracks().forEach(t => t.stop());\n if (rafRef.current) cancelAnimationFrame(rafRef.current);\n };\n }, []);\n\n const capturedUrlRef = useRef('');\n useEffect(() => { capturedUrlRef.current = capturedUrl; }, [capturedUrl]);\n useEffect(() => {\n return () => { if (capturedUrlRef.current) URL.revokeObjectURL(capturedUrlRef.current); };\n }, []);\n\n const attachStream = useCallback(async (stream: MediaStream) => {\n const video = videoRef.current!;\n video.srcObject = stream;\n await video.play();\n }, []);\n\n // ── initial boot ──────────────────────────────────────────────────────────\n useEffect(() => {\n let cancelled = false;\n (async () => {\n try {\n setStatusMsg('Loading OpenCV…');\n await initWorker();\n if (cancelled) return;\n setStatusMsg('Opening camera…');\n const stream = await openCamera();\n if (cancelled) { stream.getTracks().forEach(t => t.stop()); return; }\n streamRef.current = stream;\n await attachStream(stream);\n setScanState('scanning');\n } catch (e: unknown) {\n if (!cancelled) {\n setErrorMsg(e instanceof Error ? e.message : 'Camera or OpenCV error');\n setScanState('error');\n }\n }\n })();\n return () => { cancelled = true; };\n }, [attachStream]);\n\n // ── overlay drawing ───────────────────────────────────────────────────────\n const drawOverlay = useCallback((found: number, result?: DetectionResult) => {\n const canvas = overlayRef.current;\n const video = videoRef.current;\n if (!canvas || !video) return;\n const ctx = canvas.getContext('2d')!;\n const rect = video.getBoundingClientRect();\n\n if (rect.width !== overlayDimRef.current.w || rect.height !== overlayDimRef.current.h) {\n canvas.width = rect.width;\n canvas.height = rect.height;\n overlayDimRef.current = { w: rect.width, h: rect.height };\n }\n ctx.clearRect(0, 0, canvas.width, canvas.height);\n\n const allFound = found === 4;\n const color = allFound ? '#00ff88' : '#ff4444';\n const label = allFound ? '✓ All markers found' : `${found} / 4 markers found`;\n const bw = canvas.width, bh = canvas.height;\n const len = Math.min(bw, bh) * BRACKET_LEN_RATIO;\n\n ctx.strokeStyle = color;\n ctx.lineWidth = 3;\n const drawBracket = (x: number, y: number, dx: number, dy: number) => {\n ctx.beginPath();\n ctx.moveTo(x + dx * len, y); ctx.lineTo(x, y); ctx.lineTo(x, y + dy * len);\n ctx.stroke();\n };\n drawBracket(BRACKET_INSET, BRACKET_INSET, 1, 1);\n drawBracket(bw - BRACKET_INSET, BRACKET_INSET, -1, 1);\n drawBracket(BRACKET_INSET, bh - BRACKET_INSET, 1, -1);\n drawBracket(bw - BRACKET_INSET, bh - BRACKET_INSET,-1, -1);\n\n if (result) {\n const sx = bw / result.imageWidth;\n const sy = bh / result.imageHeight;\n const markerColors: Record<number, string> = { 0: '#00e5ff', 1: '#ffe100', 2: '#ff4f00' };\n const markerLabels: Record<number, string> = { 0: 'TL', 1: 'BR', 2: 'BL' };\n\n for (const m of result.arucoMarkers) {\n const mc = markerColors[m.id] ?? '#ffffff';\n ctx.strokeStyle = mc; ctx.lineWidth = 2;\n ctx.beginPath();\n m.corners.forEach((p, i) => {\n if (i === 0) ctx.moveTo(p.x * sx, p.y * sy); else ctx.lineTo(p.x * sx, p.y * sy);\n });\n ctx.closePath(); ctx.stroke();\n ctx.fillStyle = mc; ctx.font = 'bold 12px sans-serif'; ctx.textAlign = 'left';\n ctx.fillText(markerLabels[m.id] ?? `A${m.id}`, m.center.x * sx + 4, m.center.y * sy - 4);\n }\n if (result.qrCode.found) {\n ctx.fillStyle = '#00ff88'; ctx.font = 'bold 12px sans-serif'; ctx.textAlign = 'left';\n ctx.fillText('QR ✓', 4, 16);\n }\n }\n\n ctx.fillStyle = 'rgba(0,0,0,0.55)';\n ctx.fillRect(0, bh - STATUS_BAR_H, bw, STATUS_BAR_H);\n ctx.fillStyle = color;\n ctx.font = `bold ${Math.round(bh * 0.022 + 11)}px sans-serif`;\n ctx.textAlign = 'center';\n ctx.fillText(label, bw / 2, bh - 12);\n }, []);\n\n // ── capture ───────────────────────────────────────────────────────────────\n // captureCanvasRef 在检测循环顶部已同步冻结当前帧,直接转 blob 即可\n const captureBestFrame = useCallback(() => {\n const canvas = captureCanvasRef.current!;\n streamRef.current?.getTracks().forEach(t => t.stop());\n streamRef.current = null;\n canvas.toBlob(blob => {\n if (!blob) return;\n if (capturedUrlRef.current) URL.revokeObjectURL(capturedUrlRef.current);\n setCapturedUrl(URL.createObjectURL(blob));\n }, 'image/png');\n setScanState('captured');\n setQrValue(qrValueRef.current);\n }, []);\n\n // ── detection loop(rAF)────────────────────────────────────────────────\n const startDetection = useCallback(() => {\n if (rafRef.current) cancelAnimationFrame(rafRef.current);\n detectingRef.current = false;\n validCountRef.current = 0;\n\n const loop = async () => {\n const video = videoRef.current;\n if (!video || video.readyState < 2 || detectingRef.current) {\n rafRef.current = requestAnimationFrame(loop);\n return;\n }\n detectingRef.current = true;\n try {\n const { videoWidth: vw, videoHeight: vh } = video;\n\n // 同步冻结当前帧到 captureCanvas(await 前)\n const captureCanvas = captureCanvasRef.current!;\n if (captureCanvas.width !== vw || captureCanvas.height !== vh) {\n captureCanvas.width = vw; captureCanvas.height = vh;\n }\n captureCanvas.getContext('2d')!.drawImage(video, 0, 0);\n\n const scale = Math.min(1, SCAN_DIM / Math.max(vw, vh));\n const w = Math.round(vw * scale), h = Math.round(vh * scale);\n if (!offscreenRef.current || offscreenDimRef.current.w !== w || offscreenDimRef.current.h !== h) {\n offscreenRef.current = new OffscreenCanvas(w, h);\n offscreenDimRef.current = { w, h };\n }\n const ctx = offscreenRef.current.getContext('2d', { willReadFrequently: true }) as OffscreenCanvasRenderingContext2D;\n ctx.drawImage(video, 0, 0, w, h);\n const result = await detectInImageData(ctx.getImageData(0, 0, w, h));\n\n // ── QR 检测 ───────────────────────────────────────────────────────\n if (barcodeDetectorRef.current === undefined) {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const BD = (window as any).BarcodeDetector;\n barcodeDetectorRef.current = BD ? new BD({ formats: ['qr_code'] }) : null;\n }\n const hasAllAruco = [0, 1, 2].every(id => result.arucoMarkers.some(m => m.id === id));\n if (hasAllAruco) {\n if (qrValueRef.current) {\n result.qrCode = { found: true }; // 已解码,复用缓存\n } else {\n const qrCrop = estimateQRCropBox(result.arucoMarkers, w, h, vw, vh);\n if (qrCrop) {\n const cropScale = Math.min(1, QR_REALTIME_CROP_PX / Math.max(qrCrop.w, qrCrop.h));\n const qrW = Math.max(1, Math.round(qrCrop.w * cropScale));\n const qrH = Math.max(1, Math.round(qrCrop.h * cropScale));\n if (!qrCropCanvasRef.current ||\n qrCropCanvasRef.current.width !== qrW || qrCropCanvasRef.current.height !== qrH) {\n qrCropCanvasRef.current = new OffscreenCanvas(qrW, qrH);\n }\n const qrCtx = qrCropCanvasRef.current.getContext('2d', { willReadFrequently: true }) as OffscreenCanvasRenderingContext2D;\n qrCtx.drawImage(captureCanvas, qrCrop.x0, qrCrop.y0, qrCrop.w, qrCrop.h, 0, 0, qrW, qrH);\n const qrDet = await detectQRInCrop(qrCropCanvasRef.current!, { barcodeDetector: barcodeDetectorRef.current });\n if (qrDet.found) {\n result.qrCode = { found: true };\n if (qrDet.rawValue) qrValueRef.current = qrDet.rawValue;\n }\n }\n }\n }\n\n const found = countFound(result);\n setFoundCount(found);\n drawOverlay(found, result);\n\n if (found === 4) {\n validCountRef.current += 1;\n if (validCountRef.current >= VALID_FRAMES_REQUIRED) {\n if (rafRef.current) cancelAnimationFrame(rafRef.current);\n rafRef.current = null;\n captureBestFrame();\n return;\n }\n } else {\n validCountRef.current = 0;\n }\n } catch (e) {\n console.warn('[useScanner] detection error', e);\n validCountRef.current = 0;\n } finally {\n detectingRef.current = false;\n }\n rafRef.current = requestAnimationFrame(loop);\n };\n rafRef.current = requestAnimationFrame(loop);\n }, [drawOverlay, captureBestFrame]);\n\n useEffect(() => {\n if (scanState === 'scanning') startDetection();\n return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };\n }, [scanState, startDetection]);\n\n // ── restart ───────────────────────────────────────────────────────────────\n const restart = useCallback(async () => {\n if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; }\n streamRef.current?.getTracks().forEach(t => t.stop());\n streamRef.current = null;\n validCountRef.current = 0;\n if (capturedUrlRef.current) { URL.revokeObjectURL(capturedUrlRef.current); setCapturedUrl(''); }\n setFoundCount(0); setQrValue(''); qrValueRef.current = '';\n setErrorMsg(''); setStatusMsg('Opening camera…'); setScanState('init');\n try {\n const stream = await openCamera();\n streamRef.current = stream;\n await attachStream(stream);\n setScanState('scanning');\n } catch (e) {\n setErrorMsg(e instanceof Error ? e.message : 'Camera error');\n setScanState('error');\n }\n }, [attachStream]);\n\n return {\n videoRef, overlayRef, captureCanvasRef,\n scanState, capturedUrl, errorMsg, statusMsg, foundCount, qrValue,\n restart,\n };\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@dcg-overseas/scanner",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "import": "./dist/index.mjs",
8
+ "require": "./dist/index.cjs",
9
+ "types": "./dist/index.d.ts"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "dependencies": {
16
+ "@dcg-overseas/core": "0.1.1",
17
+ "@dcg-overseas/types": "0.1.1",
18
+ "@dcg-overseas/cv-worker": "0.1.6"
19
+ },
20
+ "devDependencies": {
21
+ "@types/react": "^19.2.14",
22
+ "react": "^19.2.4",
23
+ "tsup": "^8.0.0",
24
+ "typescript": "~5.9.3"
25
+ },
26
+ "peerDependencies": {
27
+ "react": "^18.0.0 || ^19.0.0"
28
+ },
29
+ "scripts": {
30
+ "build": "tsup",
31
+ "typecheck": "tsc --noEmit"
32
+ }
33
+ }