feat: add barcode detection support with zxing-wasm and barcode-detector
This commit is contained in:
+3
-1
@@ -22,10 +22,12 @@
|
||||
"@robonen/platform": "^0.0.5",
|
||||
"@robonen/stdlib": "^0.0.12",
|
||||
"@robonen/vue": "^0.2.0",
|
||||
"barcode-detector": "^3.2.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide": "^1.29.0",
|
||||
"vue": "3.6.0-rc.2",
|
||||
"vue-sync-engine": "link:../vue-sync-engine/lib"
|
||||
"vue-sync-engine": "link:../vue-sync-engine/lib",
|
||||
"zxing-wasm": "3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/eslint": "^0.0.1",
|
||||
|
||||
Generated
+44
@@ -23,6 +23,9 @@ importers:
|
||||
'@robonen/vue':
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0(vue@3.6.0-rc.2(typescript@6.0.3))
|
||||
barcode-detector:
|
||||
specifier: ^3.2.1
|
||||
version: 3.2.1(@types/emscripten@1.41.5)
|
||||
date-fns:
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0
|
||||
@@ -35,6 +38,9 @@ importers:
|
||||
vue-sync-engine:
|
||||
specifier: link:../vue-sync-engine/lib
|
||||
version: link:../vue-sync-engine/lib
|
||||
zxing-wasm:
|
||||
specifier: 3.1.1
|
||||
version: 3.1.1(@types/emscripten@1.41.5)
|
||||
devDependencies:
|
||||
'@robonen/eslint':
|
||||
specifier: ^0.0.1
|
||||
@@ -1242,6 +1248,9 @@ packages:
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/emscripten@1.41.5':
|
||||
resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}
|
||||
|
||||
'@types/esrecurse@4.3.1':
|
||||
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
|
||||
|
||||
@@ -1699,6 +1708,9 @@ packages:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
barcode-detector@3.2.1:
|
||||
resolution: {integrity: sha512-zLL7AbT9uNJBUzYpKg9v5tUA4yQGReExSi60q0g660Mj0wjUhKmN0GrUF3qELo8ITW9THzyJXdZQkXLMnsieiw==}
|
||||
|
||||
baseline-browser-mapping@2.11.12:
|
||||
resolution: {integrity: sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -2949,6 +2961,10 @@ packages:
|
||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
tagged-tag@1.0.0:
|
||||
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
tailwindcss@4.3.3:
|
||||
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
|
||||
|
||||
@@ -3007,6 +3023,10 @@ packages:
|
||||
resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
type-fest@5.8.0:
|
||||
resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
typed-array-buffer@1.0.3:
|
||||
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3355,6 +3375,11 @@ packages:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
zxing-wasm@3.1.1:
|
||||
resolution: {integrity: sha512-g0sPJBIubO6zLcJh1jftLPIN6xziaqLsvLgtpGKwDrEhyXXqla3E3yjFrznlr78UHIOMzbJPi0HDWKs/KgaB7A==}
|
||||
peerDependencies:
|
||||
'@types/emscripten': '>=1.39.6'
|
||||
|
||||
snapshots:
|
||||
|
||||
'@apideck/better-ajv-errors@0.3.7(ajv@8.20.0)':
|
||||
@@ -4513,6 +4538,8 @@ snapshots:
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/emscripten@1.41.5': {}
|
||||
|
||||
'@types/esrecurse@4.3.1': {}
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
@@ -4982,6 +5009,12 @@ snapshots:
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
barcode-detector@3.2.1(@types/emscripten@1.41.5):
|
||||
dependencies:
|
||||
zxing-wasm: 3.1.1(@types/emscripten@1.41.5)
|
||||
transitivePeerDependencies:
|
||||
- '@types/emscripten'
|
||||
|
||||
baseline-browser-mapping@2.11.12: {}
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
@@ -6325,6 +6358,8 @@ snapshots:
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
tagged-tag@1.0.0: {}
|
||||
|
||||
tailwindcss@4.3.3: {}
|
||||
|
||||
tapable@2.3.3: {}
|
||||
@@ -6375,6 +6410,10 @@ snapshots:
|
||||
|
||||
type-fest@0.16.0: {}
|
||||
|
||||
type-fest@5.8.0:
|
||||
dependencies:
|
||||
tagged-tag: 1.0.0
|
||||
|
||||
typed-array-buffer@1.0.3:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -6786,3 +6825,8 @@ snapshots:
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zxing-wasm@3.1.1(@types/emscripten@1.41.5):
|
||||
dependencies:
|
||||
'@types/emscripten': 1.41.5
|
||||
type-fest: 5.8.0
|
||||
|
||||
@@ -1,20 +1,57 @@
|
||||
import { onMounted, onUnmounted, shallowRef } from 'vue';
|
||||
import { useRef } from 'vue-jsx-vapor';
|
||||
|
||||
/** Есть ли в этом браузере нативное распознавание штрихкодов (Chromium). */
|
||||
export function isBarcodeScanSupported(): boolean {
|
||||
return typeof BarcodeDetector !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
|
||||
}
|
||||
|
||||
const SCAN_INTERVAL_MS = 300;
|
||||
import type { BarcodeFormat, DetectedBarcode } from 'barcode-detector/ponyfill';
|
||||
// Только URL: сам wasm в бандл не попадает, он эмитится отдельным файлом и
|
||||
// скачивается лишь тогда, когда до него дойдёт дело. Версия zxing-wasm в
|
||||
// package.json приколочена намертво — обвязка и .wasm обязаны быть из одного
|
||||
// релиза, иначе модуль не инициализируется.
|
||||
import wasmUrl from 'zxing-wasm/reader/zxing_reader.wasm?url';
|
||||
|
||||
/**
|
||||
* Камера + BarcodeDetector: распознали EAN/UPC — отдали наверх и погасили
|
||||
* камеру. Компонент монтируется только после isBarcodeScanSupported().
|
||||
* Сканер доступен везде, где есть камера. Нативный Shape Detection API —
|
||||
* только в Chromium; в WebKit (Safari и любой браузер на iOS, включая Chrome)
|
||||
* BarcodeDetector не реализован, там работает wasm-сборка zxing.
|
||||
*/
|
||||
export function isBarcodeScanSupported(): boolean {
|
||||
return !!navigator.mediaDevices?.getUserMedia;
|
||||
}
|
||||
|
||||
const FORMATS: BarcodeFormat[] = ['ean_13', 'ean_8', 'upc_a', 'upc_e', 'code_128'];
|
||||
const SCAN_INTERVAL_MS = 300;
|
||||
|
||||
interface Detector {
|
||||
detect: (source: HTMLVideoElement) => Promise<DetectedBarcode[]>;
|
||||
}
|
||||
|
||||
type NativeDetector = new (options?: { formats?: BarcodeFormat[] }) => Detector;
|
||||
|
||||
/**
|
||||
* Нативный детектор бесплатен и энергоэффективнее, поэтому wasm подключается
|
||||
* только там, где своего распознавания нет.
|
||||
*/
|
||||
async function createDetector(): Promise<Detector> {
|
||||
const Native = (globalThis as { BarcodeDetector?: NativeDetector }).BarcodeDetector;
|
||||
if (Native) return new Native({ formats: FORMATS });
|
||||
|
||||
const { BarcodeDetector, prepareZXingModule } = await import('barcode-detector/ponyfill');
|
||||
// По умолчанию zxing тянет .wasm с CDN — это ломало бы сканер офлайн и
|
||||
// упиралось бы в CSP, поэтому подставляем файл из собственной сборки.
|
||||
await prepareZXingModule({
|
||||
overrides: {
|
||||
locateFile: (path: string, prefix: string) => (path.endsWith('.wasm') ? wasmUrl : `${prefix}${path}`),
|
||||
},
|
||||
fireImmediately: true,
|
||||
});
|
||||
return new BarcodeDetector({ formats: FORMATS });
|
||||
}
|
||||
|
||||
/**
|
||||
* Камера + детектор: распознали EAN/UPC — отдали наверх и погасили камеру.
|
||||
*/
|
||||
export default function BarcodeScanner(props: { onDetected: (code: string) => void; onCancel: () => void }) {
|
||||
const videoEl = useRef();
|
||||
const error = shallowRef('');
|
||||
const loading = shallowRef(true);
|
||||
|
||||
let stream: MediaStream | null = null;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -32,15 +69,18 @@ export default function BarcodeScanner(props: { onDetected: (code: string) => vo
|
||||
if (!video) return;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment' },
|
||||
video: { facingMode: { ideal: 'environment' } },
|
||||
audio: false,
|
||||
});
|
||||
// Пока ждали разрешение, лист мог закрыться — поток нужно погасить.
|
||||
if (finished) return;
|
||||
video.srcObject = stream;
|
||||
await video.play();
|
||||
|
||||
const detector = new BarcodeDetector({
|
||||
formats: ['ean_13', 'ean_8', 'upc_a', 'upc_e', 'code_128'],
|
||||
});
|
||||
const detector = await createDetector();
|
||||
if (finished) return;
|
||||
loading.value = false;
|
||||
|
||||
timer = setInterval(async () => {
|
||||
if (finished || video.readyState < 2) return;
|
||||
try {
|
||||
@@ -58,26 +98,37 @@ export default function BarcodeScanner(props: { onDetected: (code: string) => vo
|
||||
}, SCAN_INTERVAL_MS);
|
||||
}
|
||||
catch (cause) {
|
||||
error.value = cause instanceof DOMException && cause.name === 'NotAllowedError'
|
||||
? 'Нет доступа к камере — разрешите его в настройках браузера.'
|
||||
: 'Не удалось включить камеру.';
|
||||
if (cause instanceof DOMException && cause.name === 'NotAllowedError') {
|
||||
error.value = 'Нет доступа к камере — разрешите его в настройках браузера.';
|
||||
}
|
||||
else if (stream) {
|
||||
// Камера уже работает, значит споткнулись на загрузке распознавания.
|
||||
error.value = 'Не удалось загрузить распознавание штрихкодов. Введите цифры вручную.';
|
||||
}
|
||||
else {
|
||||
error.value = 'Не удалось включить камеру.';
|
||||
}
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(stop);
|
||||
onUnmounted(() => {
|
||||
finished = true;
|
||||
stop();
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="overflow-hidden rounded-2xl border hairline bg-black/60">
|
||||
{error.value === ''
|
||||
? (
|
||||
<div class="relative">
|
||||
<video ref={videoEl} muted playsinline class="aspect-[4/3] w-full object-cover" />
|
||||
<video ref={videoEl} muted playsinline class="aspect-4/3 w-full object-cover" />
|
||||
{/* Рамка прицела */}
|
||||
<div class="pointer-events-none absolute inset-0 grid place-items-center">
|
||||
<div class="h-24 w-56 rounded-xl border-2 border-ember-bright/80 shadow-[0_0_0_999px_rgba(0,0,0,0.35)]" />
|
||||
</div>
|
||||
<p class="absolute right-0 bottom-2 left-0 text-center text-[12px] text-ink/90">
|
||||
Наведите на штрихкод упаковки
|
||||
{loading.value ? 'Готовим распознавание…' : 'Наведите на штрихкод упаковки'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
Vendored
-12
@@ -1,13 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// BarcodeDetector (Chromium): в lib.dom его ещё нет — минимальная декларация.
|
||||
interface DetectedBarcode {
|
||||
rawValue: string;
|
||||
format: string;
|
||||
}
|
||||
|
||||
declare class BarcodeDetector {
|
||||
constructor(options?: { formats?: string[] });
|
||||
detect(source: CanvasImageSource): Promise<DetectedBarcode[]>;
|
||||
static getSupportedFormats(): Promise<string[]>;
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ export default function StatsScreen() {
|
||||
class="absolute right-0 left-0 z-10 border-t border-dashed border-ink-soft/40"
|
||||
style={{ bottom: `${(target.value / chartMax.value) * 100}%` }}
|
||||
/>
|
||||
<div class="flex h-full items-end gap-[3px]">
|
||||
<div class="flex h-full items-end gap-0.75">
|
||||
{days.value.map(day => (
|
||||
<button
|
||||
type="button"
|
||||
@@ -177,7 +177,7 @@ export default function StatsScreen() {
|
||||
onClick={() => (selectedBar.value = day.date)}
|
||||
>
|
||||
<div
|
||||
class={`w-full rounded-t-[4px] transition-colors ${day.kcal > 0 ? '' : 'min-h-[2px]'} ${selectedBar.value === day.date
|
||||
class={`w-full rounded-t-sm transition-colors ${day.kcal > 0 ? '' : 'min-h-0.5'} ${selectedBar.value === day.date
|
||||
? 'bg-ember-bright'
|
||||
: day.kcal > target.value ? 'bg-over/80 group-hover:bg-over' : 'bg-ember/65 group-hover:bg-ember'}`}
|
||||
style={{ height: `${Math.max((day.kcal / chartMax.value) * 100, day.kcal > 0 ? 2 : 1)}%` }}
|
||||
|
||||
+14
-1
@@ -36,8 +36,21 @@ export default defineConfig({
|
||||
workbox: {
|
||||
// woff тут дубли woff2 от fontsource — в прекеш идёт только woff2.
|
||||
globPatterns: ['**/*.{js,css,html,woff2,svg,png,ico}'],
|
||||
// Штрихкоды из Open Food Facts: сеть свежее, но офлайн отдаём кэш.
|
||||
runtimeCaching: [
|
||||
// Распознавание штрихкодов на WebKit — ~1 МБ wasm. В прекеш его класть
|
||||
// жалко (нужен не всем), поэтому кэшируем после первого запуска
|
||||
// сканера: дальше он работает и офлайн. Файл с хэшем в имени, так что
|
||||
// CacheFirst безопасен.
|
||||
{
|
||||
urlPattern: /\.wasm$/,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'wasm',
|
||||
expiration: { maxEntries: 4 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
// Штрихкоды из Open Food Facts: сеть свежее, но офлайн отдаём кэш.
|
||||
{
|
||||
urlPattern: /^https:\/\/world\.openfoodfacts\.org\/.*/,
|
||||
handler: 'NetworkFirst',
|
||||
|
||||
Reference in New Issue
Block a user