Если чек принимает ваш собственный интерфейс, а не наш виджет, вам нужна та часть, которая читает QR с камеры и с фотографии. Мы вырезали её из боевого виджета fnfd отдельным файлом — без интерфейса, без стилей и без обращений к нашему API. Берите как есть или как образец.
https://fnfd.ru/assets/js/fnfd-qr-scanner.js (52 КБ; движок ZXing — 1 МБ wasm — подтягивается лениво и только при необходимости)https://widget.fnfd.ru/widget/fnfd-widget.js (демо)POST /api/receipts/qr, см. руководство по интеграцииСкрипт кладите к себе: адрес выше — наш прод, но зависеть от чужого домена в критичном месте приёма чека не стоит.
<video id="cam" playsinline muted style="width:100%;max-width:420px"></video>
<script src="/js/fnfd-qr-scanner.js"></script>
<script>
let stopScan = null;
async function startScan() {
const video = document.getElementById("cam");
stopScan = await FnfdQrScanner.scanVideo(video, (text) => {
if (!FnfdQrScanner.isFiscalQr(text)) return false; // не чек — сканируем дальше
sendReceipt(text); // ваш вызов POST /api/receipts/qr
return true; // true — камера гаснет
});
}
// stopScan() вызывайте всегда: при закрытии окна сканирования, уходе со страницы,
// по таймауту. Иначе камера продолжит гореть.
</script>
Фото или скриншот чека:
const text = await FnfdQrScanner.decodeFile(file); // File / Blob
if (text && FnfdQrScanner.isFiscalQr(text)) {
sendReceipt(text); // лёгкая строка вместо мегабайтной картинки
} else {
sendPhoto(file); // QR не читается — пусть распознаёт бэкенд
}
Эта развилка экономит и трафик пользователя, и время ответа: строка проверяется в ФНС сразу, фото уходит в очередь распознавания.
| Метод | Что делает |
|---|---|
scanVideo(video, onText, opts) | Включает камеру и читает кадры, пока onText не вернёт true. Резолвится функцией остановки. |
decodeFile(file, opts) | Читает QR с картинки. Резолвится строкой или null — «не нашли» это не ошибка. |
isFiscalQr(text, country) | Похоже ли на фискальный QR: "ru" — ФНС, "uz" — ОФД ГНК, без аргумента — любой из двух. |
torch(video, on) | Подсветка, если камера умеет. Резолвится false, если нет. |
focus(video) | Толчок автофокуса. Повесьте на кнопку и на тап по превью. |
listCameras() | Все камеры устройства: {deviceId, label, facing}. Метки доступны только после выданного разрешения. |
backCameras(list) | Из списка выше — только тыловые. Камеру с неизвестной стороной тыловой не считает. |
selfTest() | Прогон движков по эталонному QR: {nativePresent, native, jsqr}. |
decodeOnServer(blob, {url, apiKey}) | Отдать кадр на разбор серверу (zbar), когда браузер не справился. |
locateCode(src, w, h) | Найти «глаза» QR и вернуть его прямоугольник. Внутренний, но полезен для отладки. |
hasNativeDetector() | Есть ли в браузере BarcodeDetector (диагностика, в логи). |
opts для scanVideo:
| Опция | По умолчанию | Что делает |
|---|---|---|
deviceId | — | конкретный объектив; без него берётся запомненный удачный, иначе решает браузер |
autoLens | true | не нашли за lensTryMs — переключиться на следующий тыловой объектив |
lensTryMs | 6000 | сколько ищем одним объективом |
rememberLens | true | запоминать удачный объектив в localStorage |
country | оба | "ru" / "uz" — какой формат считать чеком |
onDiag(d) | — | разрешение, движок, объектив, кадры/сек, попытки, резкость, яркость, снимки |
onHint(h) | — | подсказки пользователю по времени: кадрирование, тап-фокус, «снимите фото», смена объектива |
onStill(blob) | — | полноразмерный снимок, который сканер сделал сам |
serverDecode | — | {url, apiKey} — разбор кадра на сервере (zbar), когда браузер не справился |
still | true | делать полноразмерные снимки через ImageCapture |
zoom | false | перебирать кратность 1× → 2× → 3×; по умолчанию не трогаем |
locate | true | искать «глаза» QR своим локатором (третья попытка по кадру) |
roi | 0.6 | доля кадра под центральный кроп |
forceEngine | — | "native" / "zxing" / "jsqr" — для замеров |
jsqrUrl, zxingUrl | наш прод | свои адреса вендорных движков |
Разбор живой жалобы 09.09.2026 дал три причины, и ни одна из них не «слабый декодер вообще».
Движок может быть слепым. BarcodeDetector в браузере может присутствовать и не работать: на Android он делегирует распознавание модулю Play Services, и если модуль не установлен, detect() молча возвращает пустой список на каждом кадре. Сканер при этом честно «работает» — вечно. Поэтому движок проверяется эталонным QR (FnfdQrScanner.selfTest()), и если он слеп, не используется вовсе.
Нельзя ужимать весь кадр. Это ошибка, которую я допустил, разгоняя сканер: кадр ужимался до 900 px целиком. Когда чек держат далеко и код занимает 8-10% ширины кадра, после ужатия от него остаётся 40-50 px — не читает никто. Замер на кадрах 1080×1920: код в 30% и 15% ширины ужатый кадр берёт, код в 10% и 8% — уже нет, а центральный кроп в исходном разрешении берёт все четыре. Поэтому масштабы чередуются: весь кадр ужатый (код крупный), центр 60% и центр 35% — в нативном разрешении. Полноразмерный снимок разбирается плитками 3×3 с перекрытием, тоже без общего ужатия.
Дело было в поиске кода, а не в его чтении. Библиотечный поиск в быстром режиме рассчитан на код, занимающий заметную долю кадра: мелкий он просто не находит — до декодирования дело не доходит. Включённый tryHarder меняет именно это: он ищет код многомасштабно. Замер на кадрах 1080×1920 (ZXing):
| Доля ширины кадра | 900 px, быстрый режим | весь кадр + tryHarder | центр 40% + tryHarder |
|---|---|---|---|
| 20% | да | да | да |
| 12% | да | да | да |
| 10% | нет | да | да |
| 8% | нет | да | да |
| 6% | нет | нет | да |
| 10%, код у края | нет | да | нет |
Цена вопроса: 6 мс на попытку против 3 мс (в живом цикле кадр ограничен 1080 px — 3,5 мс, читаемость та же) — то есть «экономия» на быстром режиме была ложной. Поэтому основная попытка теперь — весь кадр в исходном разрешении с tryHarder, а через кадр — центр 40% для совсем мелкого кода. Дополнительно работает свой локатор: он ищет «глаза» QR (профиль 1:1:3:1:1) и, если находит, отдаёт декодеру плотный вырез — это ускоряет обычные случаи, но на мелком коде помогает не всегда, поэтому он идёт третьей попыткой, а не вместо основной.
Частота попыток тоже важна — но не ценой масштаба. Попытка идёт на каждый кадр видео (requestVideoFrameCallback), а не по таймеру: так сканер ловит те доли секунды, когда автофокус успел навестись. Дорогие вещи вынесены из горячего пути — локатор работает раз в три кадра, оценка резкости раз в восемь.
Фокус. Чек подносят вплотную, ближе минимальной дистанции фокусировки — камера физически не наводится, и в кадре мыло при любом разрешении. Поэтому: непрерывный автофокус, толчок автофокуса по таймеру и по тапу на превью, наведение на найденный код через pointsOfInterest, подсказки по времени («QR должен занимать примерно треть кадра», «нажмите, чтобы навести фокус», «снимите фото») и оценка резкости кадра, которую можно показать пользователю.
Замер на живом устройстве (Samsung, четыре камеры — две тыловые, две фронтальные), один и тот же чек, одна и та же сборка сканера:
| Объектив | Диапазон фокуса | Подсветка | Результат |
|---|---|---|---|
camera 0, facing back | 0.10–4.05 м | есть | распознан за 3 кадра, 1 секунда |
camera 2, facing back | 0–0.79 м | нет | 169 попыток за 37 секунд — не распознан |
facingMode: environment отдаёт одну камеру на усмотрение браузера, и это не обязательно та, что сфокусируется на чеке в руке. Поэтому сканер теперь: пробует объектив 6 секунд, не вышло — переключается на следующий тыловой; удачный запоминает в localStorage и в следующий раз начинает с него; при тёмном кадре сам включает подсветку, если объектив её отдаёт. Опции: deviceId, autoLens: false, lensTryMs, rememberLens: false.
Фронтальные камеры в переборе не участвуют. На телефоне их обычно столько же, сколько тыловых (у устройства из замера — четыре камеры: две и две), и переключение на селфи-камеру выглядит как поломка. Сторона определяется через InputDeviceInfo.getCapabilities().facingMode, а если устройство молчит — по метке (facing back, rear, задняя…). Камеру с неизвестной стороной тыловой не считаем: лучше остаться на текущей, чем показать пользователю его же лицо. Если тыловых меньше двух — перебор просто не запускается. Фронтальная камера не запоминается как удачная, даже если каким-то образом код прочитан именно ею.
Часть разрыва закрывается кодом, часть — нет. Честный список:
| Что | Нативное приложение | Браузер |
|---|---|---|
| Выбор объектива | берёт любой физический модуль: ультраширик фокусируется с 3-5 см, теле лучше видит мелкое издалека | можно — через deviceId из enumerateDevices(); facingMode: environment всегда даёт только основную камеру |
| Фокус по области | MeteringRectangle: «наводись сюда» | pointsOfInterest есть в спецификации, реализован не везде — пробуем best-effort |
| Подсветка | всегда | только если камера отдала torch в capabilities; на части устройств не отдаёт |
| Кадры для анализа | YUV прямо с сенсора, отдельный поток 12 Мп для снимка | один поток, RGBA через канвас, копия на каждый кадр |
| Детектор | ML Kit — обученная модель, находит код смазанным и частично перекрытым | классическое зрение (ZXing) или своя эвристика |
| Экспозиция | короткая выдержка, чтобы не смазывало | exposureTime/exposureCompensation — редко доступны |
| Сцена «штрихкод» | CONTROL_SCENE_MODE_BARCODE в Camera2 | нет |
Из этого списка мы теперь используем всё, что доступно: перебор объективов (метод listCameras(), опция deviceId), наведение на найденный код через pointsOfInterest, подсветку, zoom, непрерывный автофокус и его толчок. Недоступное принципиально — ML-детектор и сцена «штрихкод»; отчасти это компенсирует tryHarder и собственный локатор.
| Движок | Что это | Когда используется |
|---|---|---|
BarcodeDetector | нативный, через Play Services | только если прошёл самопроверку |
| ZXing C++ (wasm) | тот же класс декодера, что в нативных сканерах | основной; ~1 МБ, грузится лениво |
| jsQR | лёгкий JS-декодер | пока грузится wasm и если он не загрузился |
Замер в браузере на девяти намеренно испорченных снимках (2–4 px на модуль, размытие, бледная печать, наклон, jpeg 60): jsQR 3/9, ZXing 5/9, нативный 5/9. Скорость одной попытки по живому кадру 900 px: jsQR 11 мс, ZXing 2 мс — то есть 88 против 585 попыток в секунду. Четыре снимка с размытием 0.9–2.4 px не берёт ни один движок: это и есть случай «камера не навелась», который лечится дистанцией и фокусом, а не декодером.
Если и это не помогло — код физически не восстановить: сгиб через QR, выцветшая термопечать, блик. Тогда работает ручной ввод по реквизитам, он есть в виджете.
Запасной разбор на сервере. Кадр, который не взяли браузерные движки, можно отправить на POST /api/qr (multipart photo) — там zbar в несколько проходов; ответ содержит qrString. В модуле это опция serverDecode: { url, apiKey }.
Проверить на конкретном телефоне: fnfd.ru/qr-test.html — самопроверка движков, реальное разрешение потока, поддержка фокуса и zoom, кадры в секунду, резкость кадра, превью снятого кадра и кто в итоге разобрал: браузер или сервер.
Разбор фотографии — многопроходный и на клиенте, и на сервере: целиком, центральным кропом, кропом с укрупнением. Серверный декодер на двенадцати испорченных снимках за один проход берёт 7, многопроходный — 10.
Камера работает только в secure context — https:// или localhost. По http:// браузер не покажет даже запрос доступа: scanVideo сразу отклонится. Это не наше ограничение и обойти его нельзя, поэтому ручной ввод реквизитов должен быть в интерфейсе всегда, а не «на всякий случай».
Два движка, потому что нативного не хватает. BarcodeDetector есть в Chrome и на Android — там не грузится ничего лишнего. В Safari и Firefox его нет, и модуль лениво подтягивает jsQR (~250 КБ) в момент первого сканирования, а не при загрузке страницы. Проверить, какой путь сработал у пользователя, можно через hasNativeDetector().
Гасите поток при закрытии. Между «пользователь нажал сканировать» и «браузер выдал доступ» проходит время, за которое окно успевают закрыть. Поток при этом всё равно выдаётся, и если его не остановить — камера остаётся включённой; на iOS это видно индикатором. В модуле это учтено (stop() до получения потока тоже сработает), но если пишете своё — не забудьте.
willReadFrequently: true у канваса. Без него getImageData на каждом кадре выбивает канвас с GPU-пути, и на недорогих телефонах сканирование заметно дёргается.
Фильтруйте не-чеки на клиенте. Камера ловит любой QR — с упаковки, ценника, плаката. Без isFiscalQr вы будете слать это на сервер и показывать пользователю отказы вместо подсказки «наведите на QR внизу чека».
Ручной ввод — по реквизитам, а не по строке QR. Проверено на промо-аудитории: строку QR обычный человек взять неоткуда, а ФН, ФД, ФП, дату и сумму он перепишет с бумажного чека. Как из них собрать стандартную строку — в руководстве, раздел про приём чека; готовая форма есть в боевом виджете.
Откуда берётся wasm. Модуль грузит zxing-reader.iife.js обычным <script>, а zxing_reader.wasm — из той же папки, что и этот скрипт (по умолчанию https://fnfd.ru/assets/js/vendor/zxing/, при своём zxingUrl — рядом с ним), а не с fastly.jsdelivr.net, куда его по умолчанию отправляет сама сборка zxing-wasm. Размещая копию у себя, кладите zxing_reader.wasm рядом с zxing-reader.iife.js; если скрипт и страница на разных доменах, wasm должен отдаваться с Access-Control-Allow-Origin и Content-Type: application/wasm.
Сам модуль — наш код, берите и правьте. Запасной движок jsQR распространяется по Apache-2.0 (github.com/cozmo/jsQR): размещая его копию у себя, положите рядом текст лицензии. Наш файл assets/js/vendor/jsQR.min.js — сборка upstream без изменений.