diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..54653aa
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+dist/
+.DS_Store
+npm-debug.log*
diff --git a/PRODUCT.md b/PRODUCT.md
new file mode 100644
index 0000000..dcb3e03
--- /dev/null
+++ b/PRODUCT.md
@@ -0,0 +1,32 @@
+# Product
+
+## Register
+
+product
+
+## Users
+
+Пользователь Lineage II запускает утилиту рядом с несколькими окнами игры и хочет быстро проверить, какие данные удалось извлечь с экрана. На первом этапе это инструмент для владельца проекта, а не публичный продукт.
+
+## Product Purpose
+
+Приложение подключает одно или несколько выбранных окон, позволяет откалибровать области распознавания на снимке и показывает извлечённые названия, цены и количества. Успех прототипа означает, что весь путь от выбора окна до видимого результата работает локально на macOS и Windows.
+
+## Brand Personality
+
+Нейтральный, утилитарный, спокойный.
+
+## Anti-references
+
+Не имитировать интерфейс Lineage II, не превращать инструмент в декоративный игровой оверлей, не использовать маркетинговые dashboard-шаблоны и лишнюю анимацию.
+
+## Design Principles
+
+- Показывать реальный захваченный кадр и реальный результат, а не скрывать состояние процесса.
+- Вести пользователя по прямому пути: источник, калибровка, результат.
+- Сохранять интерфейс компактным и предсказуемым при нескольких окнах.
+- Оставлять настраиваемыми только параметры, которые зависят от реального изображения.
+
+## Accessibility & Inclusion
+
+Использовать стандартное управление с клавиатуры, заметный фокус, текстовые подписи для состояний и уважать системную настройку уменьшенного движения. Специальный уровень WCAG для прототипа не задан.
diff --git a/README.md b/README.md
index 11d7b19..5fca666 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,40 @@
-# l2-market-parser
+# L2 Market Parser
+Кроссплатформенный локальный прототип для захвата нескольких окон Lineage II, калибровки областей на скриншоте и OCR-распознавания результата.
+
+## Запуск
+
+Нужен Node.js 20 или новее и Chrome либо Edge.
+
+```bash
+npm install
+npm run dev
+```
+
+Открой адрес, который напечатает Vite, обычно `http://127.0.0.1:5173`.
+
+На macOS браузеру потребуется разрешение **System Settings → Privacy & Security → Screen & System Audio Recording**. После выдачи разрешения браузер иногда нужно перезапустить.
+
+## Сценарий
+
+1. В разделе «Источники» нажми «Добавить окно» для каждого окна игры.
+2. В разделе «Калибровка» сделай снимок или загрузи готовый скриншот.
+3. Выдели маленький неизменяемый элемент торгового окна как якорь.
+4. Выдели области названия, цены и количества.
+5. Сохрани калибровку и нажми «Распознать все».
+
+Калибровка хранится в `localStorage` текущего браузера. При первом OCR Tesseract.js загружает английскую языковую модель и кеширует её в браузере.
+
+## Ограничения прототипа
+
+- Источники выбираются вручную после каждого обновления страницы.
+- Одна калибровка рассчитана на один масштаб интерфейса Lineage II.
+- Распознавание запускается вручную, фонового сканирования и отправки на сервер пока нет.
+- Для нескольких строк товара нужно отдельно выделить поля каждой строки.
+
+## Проверки
+
+```bash
+npm test
+npm run build
+```
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..465c944
--- /dev/null
+++ b/index.html
@@ -0,0 +1,134 @@
+
+
+
+
+
+
+ L2 Market Parser
+
+
+
+
+
+
+
+ 1 Источники
+
+
+ 2 Калибровка
+
+
+ 3 Результат
+
+
+
+
+
+
+
+
Захват экрана
+
Окна игры
+
Добавь каждое окно отдельно. Браузер спросит, какое окно разрешено захватывать.
+
+
Добавить окно
+
+
+
+
+
+
+
+
Образец интерфейса
+
Калибровка
+
Сначала отметь небольшой неизменяемый якорь, затем области с названием и ценой.
+
+
+
+ Снимок из окна
+
+ Загрузить скриншот
+
+
+
+
+
+
+
+
+ Нужен скриншот
+ Сделай снимок выбранного окна или загрузи готовое изображение.
+
+
+
+
+
+
+ Что выделяем
+
+ Якорь окна торговли
+ Название предмета
+ Цена
+ Количество
+ Другой текст
+
+
+
+
+ Подпись поля
+
+
+
+ Протяни рамку вокруг небольшого постоянного элемента торгового окна.
+
+
+
+ Порог совпадения
+ 0.80
+
+
+
+
+
+
+
+ Сохранить
+ Сбросить
+
+
+
+
+
+
+
+
+
Проверка пайплайна
+
Распознанные данные
+
Для каждого окна ищется якорь, затем OCR читает откалиброванные области.
+
+
+ Копировать JSON
+ Распознать все
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..077e2ed
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1246 @@
+{
+ "name": "l2-market-parser",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "l2-market-parser",
+ "version": "0.1.0",
+ "dependencies": {
+ "@techstark/opencv-js": "5.0.0-release.1",
+ "tesseract.js": "7.0.0"
+ },
+ "devDependencies": {
+ "vite": "^7.3.6"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
+ "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
+ "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
+ "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
+ "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
+ "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
+ "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
+ "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
+ "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
+ "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
+ "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
+ "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
+ "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
+ "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
+ "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
+ "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
+ "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
+ "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
+ "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
+ "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
+ "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
+ "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
+ "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
+ "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
+ "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
+ "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@techstark/opencv-js": {
+ "version": "5.0.0-release.1",
+ "resolved": "https://registry.npmjs.org/@techstark/opencv-js/-/opencv-js-5.0.0-release.1.tgz",
+ "integrity": "sha512-PIm+eB0MFtieXoNC2GRao0dv/02sehG+Nv2nSW5D6pQm6J/4WqvDHm0RyoqOmGYQm67jdGiaOdIeTShCY3PIUg==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/bmp-js": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
+ "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/idb-keyval": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz",
+ "integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/is-url": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
+ "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-fetch": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/opencollective-postinstall": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
+ "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
+ "license": "MIT",
+ "bin": {
+ "opencollective-postinstall": "index.js"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/regenerator-runtime": {
+ "version": "0.13.11",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+ "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+ "license": "MIT"
+ },
+ "node_modules/rollup": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
+ "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.62.4",
+ "@rollup/rollup-android-arm64": "4.62.4",
+ "@rollup/rollup-darwin-arm64": "4.62.4",
+ "@rollup/rollup-darwin-x64": "4.62.4",
+ "@rollup/rollup-freebsd-arm64": "4.62.4",
+ "@rollup/rollup-freebsd-x64": "4.62.4",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.4",
+ "@rollup/rollup-linux-arm64-musl": "4.62.4",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.4",
+ "@rollup/rollup-linux-loong64-musl": "4.62.4",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.4",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.4",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.4",
+ "@rollup/rollup-linux-x64-gnu": "4.62.4",
+ "@rollup/rollup-linux-x64-musl": "4.62.4",
+ "@rollup/rollup-openbsd-x64": "4.62.4",
+ "@rollup/rollup-openharmony-arm64": "4.62.4",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.4",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.4",
+ "@rollup/rollup-win32-x64-gnu": "4.62.4",
+ "@rollup/rollup-win32-x64-msvc": "4.62.4",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tesseract.js": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
+ "integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bmp-js": "^0.1.0",
+ "idb-keyval": "^6.2.0",
+ "is-url": "^1.2.4",
+ "node-fetch": "^2.6.9",
+ "opencollective-postinstall": "^2.0.3",
+ "regenerator-runtime": "^0.13.3",
+ "tesseract.js-core": "^7.0.0",
+ "wasm-feature-detect": "^1.8.0",
+ "zlibjs": "^0.3.1"
+ }
+ },
+ "node_modules/tesseract.js-core": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
+ "integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "7.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/wasm-feature-detect": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.9.0.tgz",
+ "integrity": "sha512-zonE+xlIIYtxPy++L24ow0hAD8CICb4+FgPyROd3buyXIqsJvUEDkBgfCCoXOd1Hu3DUr0GOfnPIdcGV+YpNaA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
+ "node_modules/zlibjs": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
+ "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..c68695e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "l2-market-parser",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --host 127.0.0.1",
+ "build": "vite build",
+ "test": "node --test"
+ },
+ "dependencies": {
+ "@techstark/opencv-js": "5.0.0-release.1",
+ "tesseract.js": "7.0.0"
+ },
+ "devDependencies": {
+ "vite": "^7.3.6"
+ }
+}
diff --git a/src/main.js b/src/main.js
new file mode 100644
index 0000000..eaa7e14
--- /dev/null
+++ b/src/main.js
@@ -0,0 +1,705 @@
+import "./style.css";
+import {
+ isCalibrationReady,
+ isRectInside,
+ rectFromPoints,
+ resolveFieldRect,
+} from "./parser.js";
+import {
+ findAnchor,
+ onOcrProgress,
+ recognizeField,
+ terminateVision,
+} from "./vision.js";
+
+const STORAGE_KEY = "l2-market-parser.calibration.v1";
+const fieldNames = {
+ item: "Название",
+ price: "Цена",
+ quantity: "Количество",
+ text: "Текст",
+};
+
+function emptyCalibration() {
+ return { anchor: null, fields: [], threshold: 0.8 };
+}
+
+function loadCalibration() {
+ try {
+ const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
+ return saved?.anchor && Array.isArray(saved.fields)
+ ? { ...emptyCalibration(), ...saved }
+ : emptyCalibration();
+ } catch {
+ return emptyCalibration();
+ }
+}
+
+const state = {
+ sources: [],
+ selectedSourceId: null,
+ referenceCanvas: null,
+ calibration: loadCalibration(),
+ drawing: null,
+ results: [],
+ busy: false,
+};
+
+const elements = {
+ status: document.querySelector("#global-status"),
+ nav: [...document.querySelectorAll("[data-view]")],
+ views: [...document.querySelectorAll(".view")],
+ addSource: document.querySelector("#add-source"),
+ sourceList: document.querySelector("#source-list"),
+ calibrationSource: document.querySelector("#calibration-source"),
+ captureReference: document.querySelector("#capture-reference"),
+ referenceFile: document.querySelector("#reference-file"),
+ canvasEmpty: document.querySelector("#canvas-empty"),
+ canvas: document.querySelector("#calibration-canvas"),
+ regionType: document.querySelector("#region-type"),
+ regionLabel: document.querySelector("#region-label"),
+ drawHint: document.querySelector("#draw-hint"),
+ threshold: document.querySelector("#match-threshold"),
+ thresholdValue: document.querySelector("#threshold-value"),
+ regionCount: document.querySelector("#region-count"),
+ regionList: document.querySelector("#region-list"),
+ saveCalibration: document.querySelector("#save-calibration"),
+ clearCalibration: document.querySelector("#clear-calibration"),
+ recognizeAll: document.querySelector("#recognize-all"),
+ copyResults: document.querySelector("#copy-results"),
+ resultsList: document.querySelector("#results-list"),
+};
+
+function setStatus(message, tone = "neutral") {
+ elements.status.textContent = message;
+ elements.status.dataset.tone = tone;
+}
+
+function showView(name) {
+ elements.nav.forEach((button) => {
+ const active = button.dataset.view === name;
+ button.classList.toggle("is-active", active);
+ active
+ ? button.setAttribute("aria-current", "page")
+ : button.removeAttribute("aria-current");
+ });
+
+ elements.views.forEach((view) => {
+ const active = view.id === `view-${name}`;
+ view.classList.toggle("is-active", active);
+ view.hidden = !active;
+ });
+}
+
+function selectedSource() {
+ return state.sources.find((source) => source.id === state.selectedSourceId);
+}
+
+async function waitForVideo(video) {
+ if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && video.videoWidth) return;
+
+ await new Promise((resolve, reject) => {
+ const timeout = window.setTimeout(() => reject(new Error("Нет кадров от выбранного окна")), 5000);
+ video.addEventListener(
+ "loadeddata",
+ () => {
+ window.clearTimeout(timeout);
+ resolve();
+ },
+ { once: true },
+ );
+ });
+}
+
+async function addSource() {
+ if (!navigator.mediaDevices?.getDisplayMedia) {
+ setStatus("Браузер не поддерживает захват экрана", "error");
+ return;
+ }
+
+ try {
+ setStatus("Выбери окно игры в системном окне", "working");
+ const stream = await navigator.mediaDevices.getDisplayMedia({
+ video: { frameRate: { ideal: 5, max: 10 } },
+ audio: false,
+ });
+ const track = stream.getVideoTracks()[0];
+ const video = document.createElement("video");
+ video.autoplay = true;
+ video.muted = true;
+ video.playsInline = true;
+ video.srcObject = stream;
+ await video.play();
+ await waitForVideo(video);
+
+ const source = {
+ id: crypto.randomUUID(),
+ name: track.label || `Окно ${state.sources.length + 1}`,
+ stream,
+ video,
+ active: true,
+ };
+
+ track.addEventListener("ended", () => {
+ source.active = false;
+ renderSources();
+ setStatus(`Захват «${source.name}» остановлен`, "neutral");
+ });
+
+ state.sources.push(source);
+ state.selectedSourceId = source.id;
+ renderSources();
+ setStatus(`Добавлено: ${source.name}`, "success");
+ } catch (error) {
+ if (error.name === "NotAllowedError") {
+ setStatus("Захват отменён или не разрешён", "error");
+ } else {
+ setStatus(error.message || "Не удалось добавить окно", "error");
+ }
+ }
+}
+
+function removeSource(sourceId) {
+ const source = state.sources.find((item) => item.id === sourceId);
+ source?.stream.getTracks().forEach((track) => track.stop());
+ state.sources = state.sources.filter((item) => item.id !== sourceId);
+ state.selectedSourceId = state.sources.find((item) => item.active)?.id ?? null;
+ renderSources();
+}
+
+function chooseSource(sourceId) {
+ state.selectedSourceId = sourceId;
+ renderSources();
+}
+
+function sourceEmptyState() {
+ const empty = document.createElement("div");
+ empty.className = "empty-state empty-state--wide";
+ const title = document.createElement("strong");
+ title.textContent = "Нет подключённых окон";
+ const text = document.createElement("span");
+ text.textContent = "Нажми «Добавить окно» и выбери первое окно Lineage II.";
+ empty.append(title, text);
+ return empty;
+}
+
+function renderSources() {
+ elements.sourceList.replaceChildren();
+
+ if (!state.sources.length) {
+ elements.sourceList.append(sourceEmptyState());
+ }
+
+ state.sources.forEach((source, index) => {
+ const row = document.createElement("article");
+ row.className = "source-row";
+ row.classList.toggle("is-selected", source.id === state.selectedSourceId);
+
+ const preview = document.createElement("div");
+ preview.className = "source-row__preview";
+ const video = document.createElement("video");
+ video.autoplay = true;
+ video.muted = true;
+ video.playsInline = true;
+ video.srcObject = source.stream;
+ preview.append(video);
+
+ const info = document.createElement("div");
+ info.className = "source-row__info";
+ const kicker = document.createElement("span");
+ kicker.textContent = `Источник ${index + 1}`;
+ const name = document.createElement("h3");
+ name.textContent = source.name;
+ const stateText = document.createElement("p");
+ stateText.textContent = source.active
+ ? `${source.video.videoWidth} × ${source.video.videoHeight}, захват активен`
+ : "Захват остановлен";
+ info.append(kicker, name, stateText);
+
+ const actions = document.createElement("div");
+ actions.className = "source-row__actions";
+ const calibrate = document.createElement("button");
+ calibrate.className = "button";
+ calibrate.type = "button";
+ calibrate.textContent = "Калибровать";
+ calibrate.disabled = !source.active;
+ calibrate.addEventListener("click", () => {
+ chooseSource(source.id);
+ showView("calibration");
+ });
+ const select = document.createElement("button");
+ select.className = "button button--quiet";
+ select.type = "button";
+ select.textContent = source.id === state.selectedSourceId ? "Выбрано" : "Выбрать";
+ select.disabled = !source.active || source.id === state.selectedSourceId;
+ select.addEventListener("click", () => chooseSource(source.id));
+ const remove = document.createElement("button");
+ remove.className = "button button--quiet";
+ remove.type = "button";
+ remove.textContent = "Убрать";
+ remove.addEventListener("click", () => removeSource(source.id));
+ actions.append(calibrate, select, remove);
+
+ row.append(preview, info, actions);
+ elements.sourceList.append(row);
+ });
+
+ elements.calibrationSource.replaceChildren();
+ if (!state.sources.some((source) => source.active)) {
+ const option = document.createElement("option");
+ option.value = "";
+ option.textContent = "Нет активных окон";
+ elements.calibrationSource.append(option);
+ } else {
+ state.sources
+ .filter((source) => source.active)
+ .forEach((source) => {
+ const option = document.createElement("option");
+ option.value = source.id;
+ option.textContent = source.name;
+ option.selected = source.id === state.selectedSourceId;
+ elements.calibrationSource.append(option);
+ });
+ }
+
+ elements.captureReference.disabled = !selectedSource()?.active;
+}
+
+async function captureSource(source) {
+ if (!source?.active) throw new Error("Сначала выбери активное окно");
+ await waitForVideo(source.video);
+
+ const canvas = document.createElement("canvas");
+ canvas.width = source.video.videoWidth;
+ canvas.height = source.video.videoHeight;
+ canvas.getContext("2d", { willReadFrequently: true }).drawImage(source.video, 0, 0);
+ return canvas;
+}
+
+function setReference(canvas) {
+ state.referenceCanvas = canvas;
+ elements.canvas.width = canvas.width;
+ elements.canvas.height = canvas.height;
+ elements.canvas.hidden = false;
+ elements.canvasEmpty.hidden = true;
+ drawCalibration();
+ setStatus(`Снимок готов: ${canvas.width} × ${canvas.height}`, "success");
+}
+
+async function captureReference() {
+ try {
+ setStatus("Делаю снимок выбранного окна", "working");
+ setReference(await captureSource(selectedSource()));
+ } catch (error) {
+ setStatus(error.message, "error");
+ }
+}
+
+async function loadReferenceFile(file) {
+ if (!file) return;
+
+ try {
+ const bitmap = await createImageBitmap(file);
+ const canvas = document.createElement("canvas");
+ canvas.width = bitmap.width;
+ canvas.height = bitmap.height;
+ canvas.getContext("2d", { willReadFrequently: true }).drawImage(bitmap, 0, 0);
+ bitmap.close();
+ setReference(canvas);
+ } catch {
+ setStatus("Не удалось открыть изображение", "error");
+ }
+}
+
+function canvasPoint(event) {
+ const bounds = elements.canvas.getBoundingClientRect();
+ return {
+ x: Math.max(
+ 0,
+ Math.min(elements.canvas.width, ((event.clientX - bounds.left) / bounds.width) * elements.canvas.width),
+ ),
+ y: Math.max(
+ 0,
+ Math.min(elements.canvas.height, ((event.clientY - bounds.top) / bounds.height) * elements.canvas.height),
+ ),
+ };
+}
+
+function cropCanvas(source, rect) {
+ const canvas = document.createElement("canvas");
+ canvas.width = rect.width;
+ canvas.height = rect.height;
+ canvas
+ .getContext("2d", { willReadFrequently: true })
+ .drawImage(source, rect.x, rect.y, rect.width, rect.height, 0, 0, rect.width, rect.height);
+ return canvas;
+}
+
+function addRegion(rect) {
+ const type = elements.regionType.value;
+
+ if (type === "anchor") {
+ state.calibration.anchor = {
+ ...rect,
+ image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/png"),
+ };
+ state.calibration.fields = [];
+ elements.regionType.value = "item";
+ updateDrawHint();
+ setStatus("Якорь выбран. Теперь отметь поля", "success");
+ } else {
+ const anchor = state.calibration.anchor;
+ if (!anchor) {
+ setStatus("Сначала выдели якорь", "error");
+ return;
+ }
+
+ const sameTypeCount = state.calibration.fields.filter((field) => field.type === type).length;
+ state.calibration.fields.push({
+ id: crypto.randomUUID(),
+ type,
+ label: elements.regionLabel.value.trim() || `${fieldNames[type]} ${sameTypeCount + 1}`,
+ offsetX: rect.x - anchor.x,
+ offsetY: rect.y - anchor.y,
+ width: rect.width,
+ height: rect.height,
+ });
+ elements.regionLabel.value = "";
+ setStatus("Область добавлена", "success");
+ }
+
+ renderCalibration();
+}
+
+function fieldAbsoluteRect(field) {
+ return resolveFieldRect(
+ { x: state.calibration.anchor.x, y: state.calibration.anchor.y },
+ field,
+ );
+}
+
+function drawBox(context, rect, label, color, dashed = false) {
+ context.save();
+ context.strokeStyle = color;
+ context.fillStyle = color;
+ context.lineWidth = Math.max(2, elements.canvas.width / 900);
+ context.setLineDash(dashed ? [10, 7] : []);
+ context.strokeRect(rect.x, rect.y, rect.width, rect.height);
+
+ if (label) {
+ context.font = `${Math.max(13, elements.canvas.width / 90)}px system-ui`;
+ const width = context.measureText(label).width + 14;
+ const y = Math.max(0, rect.y - 25);
+ context.fillRect(rect.x, y, width, 25);
+ context.fillStyle = "oklch(0.98 0.004 250)";
+ context.fillText(label, rect.x + 7, y + 17);
+ }
+ context.restore();
+}
+
+function drawCalibration() {
+ if (!state.referenceCanvas) return;
+ const context = elements.canvas.getContext("2d");
+ context.clearRect(0, 0, elements.canvas.width, elements.canvas.height);
+ context.drawImage(state.referenceCanvas, 0, 0);
+
+ if (state.calibration.anchor) {
+ drawBox(context, state.calibration.anchor, "Якорь", "oklch(0.72 0.16 65)");
+ state.calibration.fields.forEach((field) => {
+ drawBox(context, fieldAbsoluteRect(field), field.label, "oklch(0.65 0.17 250)");
+ });
+ }
+
+ if (state.drawing) {
+ const rect = rectFromPoints(state.drawing.start, state.drawing.current);
+ drawBox(context, rect, "", "oklch(0.75 0.18 150)", true);
+ }
+}
+
+function updateDrawHint() {
+ const hints = {
+ anchor: "Протяни рамку вокруг небольшого постоянного элемента торгового окна.",
+ item: "Выдели только строку с названием предмета.",
+ price: "Выдели цену вместе с разделителями разрядов.",
+ quantity: "Выдели количество предметов.",
+ text: "Выдели одну строку текста.",
+ };
+ elements.drawHint.textContent = hints[elements.regionType.value];
+}
+
+function renderCalibration() {
+ elements.threshold.value = String(state.calibration.threshold);
+ elements.thresholdValue.value = state.calibration.threshold.toFixed(2);
+ elements.regionCount.textContent = String(
+ state.calibration.fields.length + (state.calibration.anchor ? 1 : 0),
+ );
+ elements.regionList.replaceChildren();
+
+ if (!state.calibration.anchor) {
+ const note = document.createElement("p");
+ note.className = "muted-copy";
+ note.textContent = "Якорь ещё не выбран.";
+ elements.regionList.append(note);
+ } else {
+ const anchor = document.createElement("div");
+ anchor.className = "region-row";
+ anchor.innerHTML = "Якорь Шаблон поиска ";
+ elements.regionList.append(anchor);
+ }
+
+ state.calibration.fields.forEach((field) => {
+ const row = document.createElement("div");
+ row.className = "region-row";
+ const copy = document.createElement("span");
+ const name = document.createElement("strong");
+ name.textContent = field.label;
+ const type = document.createElement("small");
+ type.textContent = fieldNames[field.type];
+ copy.append(name, type);
+ const remove = document.createElement("button");
+ remove.className = "icon-button";
+ remove.type = "button";
+ remove.textContent = "×";
+ remove.setAttribute("aria-label", `Удалить ${field.label}`);
+ remove.addEventListener("click", () => {
+ state.calibration.fields = state.calibration.fields.filter((item) => item.id !== field.id);
+ renderCalibration();
+ drawCalibration();
+ });
+ row.append(copy, remove);
+ elements.regionList.append(row);
+ });
+
+ drawCalibration();
+}
+
+function saveCalibration() {
+ if (!isCalibrationReady(state.calibration)) {
+ setStatus("Для сохранения нужны якорь и хотя бы одно поле", "error");
+ return;
+ }
+
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state.calibration));
+ setStatus("Калибровка сохранена в браузере", "success");
+ } catch {
+ setStatus("Браузер не смог сохранить калибровку", "error");
+ }
+}
+
+function clearCalibration() {
+ state.calibration = emptyCalibration();
+ localStorage.removeItem(STORAGE_KEY);
+ renderCalibration();
+ setStatus("Калибровка сброшена", "neutral");
+}
+
+function imageToCanvas(dataUrl) {
+ return new Promise((resolve, reject) => {
+ const image = new Image();
+ image.onload = () => {
+ const canvas = document.createElement("canvas");
+ canvas.width = image.naturalWidth;
+ canvas.height = image.naturalHeight;
+ canvas.getContext("2d").drawImage(image, 0, 0);
+ resolve(canvas);
+ };
+ image.onerror = reject;
+ image.src = dataUrl;
+ });
+}
+
+function renderResults() {
+ elements.resultsList.replaceChildren();
+ elements.copyResults.disabled = !state.results.length;
+
+ if (!state.results.length) {
+ const empty = document.createElement("div");
+ empty.className = "empty-state empty-state--wide";
+ const title = document.createElement("strong");
+ title.textContent = "Результатов пока нет";
+ const text = document.createElement("span");
+ text.textContent = "Подключи окна, сохрани калибровку и запусти распознавание.";
+ empty.append(title, text);
+ elements.resultsList.append(empty);
+ return;
+ }
+
+ state.results.forEach((result) => {
+ const section = document.createElement("section");
+ section.className = "result-group";
+ const header = document.createElement("header");
+ const heading = document.createElement("div");
+ const title = document.createElement("h3");
+ title.textContent = result.source;
+ const meta = document.createElement("p");
+ meta.textContent = result.found
+ ? `Якорь найден, совпадение ${(result.score * 100).toFixed(1)}%`
+ : `Якорь не найден, лучшее совпадение ${(result.score * 100).toFixed(1)}%`;
+ heading.append(title, meta);
+ const badge = document.createElement("span");
+ badge.className = "result-badge";
+ badge.dataset.tone = result.found ? "success" : "error";
+ badge.textContent = result.found ? "Распознано" : "Не найдено";
+ header.append(heading, badge);
+ section.append(header);
+
+ if (result.error) {
+ const error = document.createElement("p");
+ error.className = "result-error";
+ error.textContent = result.error;
+ section.append(error);
+ } else if (result.fields?.length) {
+ const table = document.createElement("table");
+ table.innerHTML = "Поле Значение OCR Уверенность ";
+ const body = document.createElement("tbody");
+ result.fields.forEach((field) => {
+ const row = document.createElement("tr");
+ const label = document.createElement("td");
+ label.textContent = field.label;
+ const value = document.createElement("td");
+ value.textContent =
+ typeof field.value === "number" ? field.value.toLocaleString("ru-RU") : field.value || "Не распознано";
+ const raw = document.createElement("td");
+ raw.textContent = field.text || "пусто";
+ const confidence = document.createElement("td");
+ confidence.textContent = `${field.confidence}%`;
+ row.append(label, value, raw, confidence);
+ body.append(row);
+ });
+ table.append(body);
+ section.append(table);
+ }
+
+ elements.resultsList.append(section);
+ });
+}
+
+async function recognizeAll() {
+ const activeSources = state.sources.filter((source) => source.active);
+ if (!activeSources.length) {
+ setStatus("Сначала добавь хотя бы одно окно", "error");
+ return;
+ }
+ if (!isCalibrationReady(state.calibration)) {
+ setStatus("Сначала сохрани якорь и области распознавания", "error");
+ return;
+ }
+ if (state.busy) return;
+
+ state.busy = true;
+ elements.recognizeAll.disabled = true;
+ elements.recognizeAll.textContent = "Распознаю…";
+ state.results = [];
+ renderResults();
+
+ onOcrProgress((progress) => {
+ if (progress.status === "recognizing text") {
+ setStatus(`OCR: ${Math.round((progress.progress || 0) * 100)}%`, "working");
+ }
+ });
+
+ try {
+ const template = await imageToCanvas(state.calibration.anchor.image);
+
+ for (const source of activeSources) {
+ setStatus(`Ищу торговое окно: ${source.name}`, "working");
+ const frame = await captureSource(source);
+ const match = await findAnchor(frame, template);
+ const result = {
+ source: source.name,
+ capturedAt: new Date().toISOString(),
+ score: match.score,
+ found: match.score >= state.calibration.threshold,
+ fields: [],
+ };
+
+ if (!result.found) {
+ result.error = "Проверь масштаб интерфейса, выбранный якорь или уменьши порог совпадения.";
+ } else {
+ for (const field of state.calibration.fields) {
+ const rect = resolveFieldRect(match, field);
+ if (!isRectInside(rect, frame.width, frame.height)) {
+ result.fields.push({
+ ...field,
+ text: "",
+ value: null,
+ confidence: 0,
+ error: "Область вышла за границы кадра",
+ });
+ continue;
+ }
+
+ setStatus(`${source.name}: ${field.label}`, "working");
+ result.fields.push({ ...field, ...(await recognizeField(frame, rect, field.type)) });
+ }
+ }
+
+ state.results.push(result);
+ renderResults();
+ }
+
+ setStatus("Распознавание завершено", "success");
+ } catch (error) {
+ setStatus(error.message || "Ошибка распознавания", "error");
+ } finally {
+ state.busy = false;
+ elements.recognizeAll.disabled = false;
+ elements.recognizeAll.textContent = "Распознать все";
+ }
+}
+
+async function copyResults() {
+ try {
+ await navigator.clipboard.writeText(JSON.stringify(state.results, null, 2));
+ setStatus("JSON скопирован", "success");
+ } catch {
+ setStatus("Не удалось скопировать JSON", "error");
+ }
+}
+
+elements.nav.forEach((button) => button.addEventListener("click", () => showView(button.dataset.view)));
+elements.addSource.addEventListener("click", addSource);
+elements.calibrationSource.addEventListener("change", (event) => chooseSource(event.target.value));
+elements.captureReference.addEventListener("click", captureReference);
+elements.referenceFile.addEventListener("change", (event) => loadReferenceFile(event.target.files[0]));
+elements.regionType.addEventListener("change", updateDrawHint);
+elements.threshold.addEventListener("input", (event) => {
+ state.calibration.threshold = Number(event.target.value);
+ elements.thresholdValue.value = state.calibration.threshold.toFixed(2);
+});
+elements.saveCalibration.addEventListener("click", saveCalibration);
+elements.clearCalibration.addEventListener("click", clearCalibration);
+elements.recognizeAll.addEventListener("click", recognizeAll);
+elements.copyResults.addEventListener("click", copyResults);
+
+elements.canvas.addEventListener("pointerdown", (event) => {
+ if (!state.referenceCanvas) return;
+ elements.canvas.setPointerCapture(event.pointerId);
+ const point = canvasPoint(event);
+ state.drawing = { start: point, current: point };
+ drawCalibration();
+});
+elements.canvas.addEventListener("pointermove", (event) => {
+ if (!state.drawing) return;
+ state.drawing.current = canvasPoint(event);
+ drawCalibration();
+});
+elements.canvas.addEventListener("pointerup", (event) => {
+ if (!state.drawing) return;
+ state.drawing.current = canvasPoint(event);
+ const rect = rectFromPoints(state.drawing.start, state.drawing.current);
+ state.drawing = null;
+ if (rect.width >= 4 && rect.height >= 4) addRegion(rect);
+ drawCalibration();
+});
+
+window.addEventListener("beforeunload", () => {
+ state.sources.forEach((source) => source.stream.getTracks().forEach((track) => track.stop()));
+ terminateVision();
+});
+
+renderSources();
+renderCalibration();
+renderResults();
+updateDrawHint();
diff --git a/src/parser.js b/src/parser.js
new file mode 100644
index 0000000..43bb600
--- /dev/null
+++ b/src/parser.js
@@ -0,0 +1,52 @@
+export function rectFromPoints(start, end) {
+ return {
+ x: Math.round(Math.min(start.x, end.x)),
+ y: Math.round(Math.min(start.y, end.y)),
+ width: Math.round(Math.abs(end.x - start.x)),
+ height: Math.round(Math.abs(end.y - start.y)),
+ };
+}
+
+export function resolveFieldRect(anchorPosition, field) {
+ return {
+ x: Math.round(anchorPosition.x + field.offsetX),
+ y: Math.round(anchorPosition.y + field.offsetY),
+ width: Math.round(field.width),
+ height: Math.round(field.height),
+ };
+}
+
+export function normalizeOcrText(text) {
+ return text.replace(/\s+/g, " ").trim();
+}
+
+export function parseFieldValue(type, text) {
+ const normalized = normalizeOcrText(text);
+
+ if (type !== "price" && type !== "quantity") {
+ return normalized;
+ }
+
+ const digits = normalized.replace(/\D/g, "");
+ return digits ? Number(digits) : null;
+}
+
+export function isCalibrationReady(calibration) {
+ return Boolean(
+ calibration?.anchor?.image &&
+ calibration.anchor.width >= 4 &&
+ calibration.anchor.height >= 4 &&
+ calibration.fields?.length,
+ );
+}
+
+export function isRectInside(rect, width, height) {
+ return (
+ rect.x >= 0 &&
+ rect.y >= 0 &&
+ rect.width > 0 &&
+ rect.height > 0 &&
+ rect.x + rect.width <= width &&
+ rect.y + rect.height <= height
+ );
+}
diff --git a/src/style.css b/src/style.css
new file mode 100644
index 0000000..b407f5b
--- /dev/null
+++ b/src/style.css
@@ -0,0 +1,754 @@
+:root {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
+ color: oklch(0.24 0.012 255);
+ background: oklch(0.965 0.006 250);
+ font-synthesis: none;
+ --background: oklch(0.965 0.006 250);
+ --surface: oklch(0.99 0.004 250);
+ --surface-raised: oklch(1 0.003 250);
+ --surface-muted: oklch(0.935 0.008 250);
+ --text: oklch(0.24 0.012 255);
+ --text-muted: oklch(0.52 0.018 255);
+ --border: oklch(0.86 0.012 250);
+ --border-strong: oklch(0.74 0.02 250);
+ --accent: oklch(0.58 0.16 250);
+ --accent-hover: oklch(0.52 0.17 250);
+ --accent-soft: oklch(0.93 0.035 250);
+ --success: oklch(0.54 0.13 155);
+ --success-soft: oklch(0.93 0.04 155);
+ --error: oklch(0.56 0.18 25);
+ --error-soft: oklch(0.94 0.04 25);
+ --warning: oklch(0.68 0.15 75);
+ --focus: oklch(0.66 0.18 250);
+ --shadow: 0 14px 40px oklch(0.32 0.025 250 / 0.08);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+ min-height: 100vh;
+ background: var(--background);
+ color: var(--text);
+}
+
+button,
+input,
+select {
+ font: inherit;
+}
+
+button,
+select,
+input[type="range"],
+.file-button {
+ cursor: pointer;
+}
+
+button:focus-visible,
+input:focus-visible,
+select:focus-visible,
+.file-button:has(input:focus-visible) {
+ outline: 3px solid color-mix(in oklch, var(--focus), transparent 50%);
+ outline-offset: 2px;
+}
+
+button:disabled {
+ cursor: not-allowed;
+ opacity: 0.52;
+}
+
+h1,
+h2,
+h3,
+p {
+ margin-block-start: 0;
+}
+
+h1 {
+ margin-block-end: 0;
+ font-size: 1.45rem;
+ line-height: 1.15;
+ letter-spacing: -0.025em;
+}
+
+h2 {
+ margin-block-end: 0.45rem;
+ font-size: 1.75rem;
+ line-height: 1.15;
+ letter-spacing: -0.035em;
+}
+
+h3 {
+ margin-block-end: 0.25rem;
+ font-size: 1rem;
+}
+
+.app-shell {
+ width: min(1480px, 100%);
+ min-height: 100vh;
+ margin-inline: auto;
+ padding: 28px clamp(18px, 3vw, 48px) 56px;
+}
+
+.app-header {
+ display: flex;
+ min-height: 62px;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+}
+
+.eyebrow {
+ margin-block-end: 5px;
+ color: var(--text-muted);
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+
+.status {
+ display: grid;
+ width: 270px;
+ min-height: 38px;
+ place-items: center;
+ padding: 8px 14px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: var(--surface);
+ color: var(--text-muted);
+ font-size: 0.84rem;
+ text-align: center;
+ transition: background-color 180ms cubic-bezier(0.22, 1, 0.36, 1),
+ border-color 180ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.status[data-tone="working"] {
+ border-color: color-mix(in oklch, var(--accent), transparent 55%);
+ background: var(--accent-soft);
+ color: var(--accent-hover);
+}
+
+.status[data-tone="success"] {
+ border-color: color-mix(in oklch, var(--success), transparent 55%);
+ background: var(--success-soft);
+ color: var(--success);
+}
+
+.status[data-tone="error"] {
+ border-color: color-mix(in oklch, var(--error), transparent 55%);
+ background: var(--error-soft);
+ color: var(--error);
+}
+
+.step-nav {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ margin-block: 30px 42px;
+ border-block-end: 1px solid var(--border);
+}
+
+.step-nav__item {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ min-height: 54px;
+ border: 0;
+ border-block-end: 2px solid transparent;
+ background: transparent;
+ color: var(--text-muted);
+ font-weight: 650;
+}
+
+.step-nav__item span {
+ display: grid;
+ width: 24px;
+ height: 24px;
+ place-items: center;
+ border: 1px solid var(--border-strong);
+ border-radius: 50%;
+ font-size: 0.75rem;
+}
+
+.step-nav__item:hover {
+ color: var(--text);
+}
+
+.step-nav__item.is-active {
+ border-block-end-color: var(--accent);
+ color: var(--accent);
+}
+
+.step-nav__item.is-active span {
+ border-color: var(--accent);
+ background: var(--accent);
+ color: oklch(0.98 0.004 250);
+}
+
+.view[hidden] {
+ display: none;
+}
+
+.section-heading {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 32px;
+ margin-block-end: 28px;
+}
+
+.section-heading > div:first-child {
+ max-width: 70ch;
+}
+
+.section-heading p:last-child {
+ margin-block-end: 0;
+ color: var(--text-muted);
+ line-height: 1.55;
+}
+
+.section-heading--compact {
+ align-items: center;
+}
+
+.button-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 9px;
+}
+
+.button,
+select,
+input[type="text"] {
+ min-height: 40px;
+ border: 1px solid var(--border-strong);
+ border-radius: 9px;
+ background: var(--surface-raised);
+ color: var(--text);
+}
+
+.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 8px 14px;
+ font-weight: 650;
+ white-space: nowrap;
+}
+
+.button:hover:not(:disabled) {
+ border-color: var(--accent);
+}
+
+.button--primary {
+ border-color: var(--accent);
+ background: var(--accent);
+ color: oklch(0.98 0.004 250);
+}
+
+.button--primary:hover:not(:disabled) {
+ border-color: var(--accent-hover);
+ background: var(--accent-hover);
+}
+
+.button--quiet {
+ border-color: transparent;
+ background: transparent;
+ color: var(--text-muted);
+}
+
+.button--quiet:hover:not(:disabled) {
+ border-color: var(--border);
+ background: var(--surface-muted);
+ color: var(--text);
+}
+
+select,
+input[type="text"] {
+ padding-inline: 11px;
+}
+
+.file-button input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+}
+
+.source-list {
+ display: grid;
+ gap: 12px;
+}
+
+.source-row {
+ display: grid;
+ grid-template-columns: 220px minmax(220px, 1fr) auto;
+ align-items: center;
+ gap: 22px;
+ padding: 14px;
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background: var(--surface);
+ transition: border-color 180ms cubic-bezier(0.22, 1, 0.36, 1),
+ box-shadow 180ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.source-row.is-selected {
+ border-color: color-mix(in oklch, var(--accent), transparent 35%);
+ box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent), transparent 88%);
+}
+
+.source-row__preview {
+ aspect-ratio: 16 / 9;
+ overflow: hidden;
+ border-radius: 8px;
+ background: oklch(0.18 0.01 255);
+}
+
+.source-row__preview video {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+
+.source-row__info span {
+ color: var(--text-muted);
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.source-row__info h3 {
+ margin-block: 5px;
+}
+
+.source-row__info p {
+ margin: 0;
+ color: var(--text-muted);
+ font-size: 0.85rem;
+}
+
+.source-row__actions {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.empty-state {
+ display: grid;
+ place-items: center;
+ gap: 8px;
+ min-height: 260px;
+ padding: 32px;
+ color: var(--text-muted);
+ text-align: center;
+}
+
+.empty-state strong {
+ color: var(--text);
+ font-size: 1rem;
+}
+
+.empty-state--wide {
+ min-height: 300px;
+ border: 1px dashed var(--border-strong);
+ border-radius: 14px;
+ background: color-mix(in oklch, var(--surface), transparent 30%);
+}
+
+.calibration-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 310px;
+ min-height: 620px;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ background: var(--surface);
+ box-shadow: var(--shadow);
+}
+
+.canvas-stage {
+ display: grid;
+ min-width: 0;
+ min-height: 620px;
+ max-height: calc(100vh - 260px);
+ place-items: center;
+ overflow: auto;
+ padding: 24px;
+ background-color: oklch(0.91 0.008 250);
+ background-image: linear-gradient(45deg, oklch(0.88 0.008 250) 25%, transparent 25%),
+ linear-gradient(-45deg, oklch(0.88 0.008 250) 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, oklch(0.88 0.008 250) 75%),
+ linear-gradient(-45deg, transparent 75%, oklch(0.88 0.008 250) 75%);
+ background-position: 0 0, 0 8px, 8px -8px, -8px 0;
+ background-size: 16px 16px;
+}
+
+#calibration-canvas {
+ display: block;
+ max-width: 100%;
+ max-height: 100%;
+ border-radius: 4px;
+ box-shadow: 0 8px 30px oklch(0.2 0.02 255 / 0.2);
+ cursor: crosshair;
+ touch-action: none;
+}
+
+.inspector {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ padding: 24px;
+ border-inline-start: 1px solid var(--border);
+ background: var(--surface);
+}
+
+.field-stack {
+ display: grid;
+ gap: 7px;
+}
+
+.field-stack label,
+.range-label label {
+ color: var(--text-muted);
+ font-size: 0.78rem;
+ font-weight: 700;
+}
+
+.hint {
+ min-height: 58px;
+ margin: 0;
+ padding: 11px 12px;
+ border: 1px solid var(--border);
+ border-radius: 9px;
+ background: var(--surface-muted);
+ color: var(--text-muted);
+ font-size: 0.82rem;
+ line-height: 1.45;
+}
+
+.range-label {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.range-label output {
+ color: var(--text);
+ font-variant-numeric: tabular-nums;
+ font-weight: 700;
+}
+
+input[type="range"] {
+ width: 100%;
+ accent-color: var(--accent);
+}
+
+.inspector__section {
+ min-height: 0;
+ flex: 1;
+ overflow: auto;
+ border-block-start: 1px solid var(--border);
+ padding-block-start: 18px;
+}
+
+.inspector__title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-block-end: 10px;
+}
+
+.inspector__title h3 {
+ margin: 0;
+}
+
+.inspector__title span {
+ display: grid;
+ min-width: 24px;
+ height: 24px;
+ place-items: center;
+ border-radius: 999px;
+ background: var(--surface-muted);
+ color: var(--text-muted);
+ font-size: 0.75rem;
+}
+
+.region-list {
+ display: grid;
+ gap: 6px;
+}
+
+.region-row {
+ display: flex;
+ min-height: 48px;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 8px 9px;
+ border-radius: 8px;
+}
+
+.region-row:hover {
+ background: var(--surface-muted);
+}
+
+.region-row span {
+ display: grid;
+ gap: 2px;
+}
+
+.region-row strong {
+ font-size: 0.84rem;
+}
+
+.region-row small {
+ color: var(--text-muted);
+ font-size: 0.72rem;
+}
+
+.icon-button {
+ display: grid;
+ width: 30px;
+ height: 30px;
+ place-items: center;
+ border: 0;
+ border-radius: 7px;
+ background: transparent;
+ color: var(--text-muted);
+ font-size: 1.2rem;
+}
+
+.icon-button:hover {
+ background: var(--error-soft);
+ color: var(--error);
+}
+
+.muted-copy {
+ color: var(--text-muted);
+ font-size: 0.82rem;
+}
+
+.inspector__actions {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 8px;
+ padding-block-start: 2px;
+}
+
+.results-list {
+ display: grid;
+ gap: 18px;
+}
+
+.result-group {
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background: var(--surface);
+}
+
+.result-group > header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 18px 20px;
+ border-block-end: 1px solid var(--border);
+}
+
+.result-group header h3 {
+ margin-block-end: 3px;
+}
+
+.result-group header p {
+ margin: 0;
+ color: var(--text-muted);
+ font-size: 0.82rem;
+}
+
+.result-badge {
+ padding: 6px 9px;
+ border-radius: 999px;
+ font-size: 0.75rem;
+ font-weight: 700;
+}
+
+.result-badge[data-tone="success"] {
+ background: var(--success-soft);
+ color: var(--success);
+}
+
+.result-badge[data-tone="error"] {
+ background: var(--error-soft);
+ color: var(--error);
+}
+
+.result-error {
+ margin: 0;
+ padding: 22px 20px;
+ color: var(--error);
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.86rem;
+}
+
+th,
+td {
+ padding: 12px 20px;
+ border-block-end: 1px solid var(--border);
+ text-align: start;
+}
+
+th {
+ background: var(--surface-muted);
+ color: var(--text-muted);
+ font-size: 0.72rem;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+
+tbody tr:last-child td {
+ border-block-end: 0;
+}
+
+td:nth-child(2),
+td:nth-child(4) {
+ font-variant-numeric: tabular-nums;
+ font-weight: 650;
+}
+
+@media (max-width: 960px) {
+ .section-heading {
+ align-items: start;
+ flex-direction: column;
+ }
+
+ .button-row {
+ justify-content: flex-start;
+ }
+
+ .source-row {
+ grid-template-columns: 180px 1fr;
+ }
+
+ .source-row__actions {
+ grid-column: 1 / -1;
+ justify-content: flex-end;
+ }
+
+ .calibration-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .inspector {
+ border-block-start: 1px solid var(--border);
+ border-inline-start: 0;
+ }
+}
+
+@media (max-width: 640px) {
+ .app-shell {
+ padding-inline: 14px;
+ }
+
+ .app-header {
+ align-items: start;
+ flex-direction: column;
+ }
+
+ .status {
+ width: 100%;
+ }
+
+ .step-nav {
+ margin-block: 22px 30px;
+ }
+
+ .step-nav__item {
+ flex-direction: column;
+ gap: 5px;
+ font-size: 0.76rem;
+ }
+
+ .source-row {
+ grid-template-columns: 1fr;
+ }
+
+ .source-row__actions {
+ grid-column: auto;
+ flex-wrap: wrap;
+ justify-content: flex-start;
+ }
+
+ .button-row,
+ .button-row > * {
+ width: 100%;
+ }
+
+ .canvas-stage {
+ min-height: 360px;
+ padding: 12px;
+ }
+
+ .result-group {
+ overflow-x: auto;
+ }
+
+ table {
+ min-width: 680px;
+ }
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ color: oklch(0.9 0.009 250);
+ background: oklch(0.18 0.012 255);
+ --background: oklch(0.18 0.012 255);
+ --surface: oklch(0.225 0.014 255);
+ --surface-raised: oklch(0.255 0.015 255);
+ --surface-muted: oklch(0.28 0.016 255);
+ --text: oklch(0.9 0.009 250);
+ --text-muted: oklch(0.68 0.018 250);
+ --border: oklch(0.34 0.018 255);
+ --border-strong: oklch(0.43 0.022 255);
+ --accent: oklch(0.7 0.15 250);
+ --accent-hover: oklch(0.75 0.13 250);
+ --accent-soft: oklch(0.3 0.05 250);
+ --success: oklch(0.72 0.12 155);
+ --success-soft: oklch(0.29 0.045 155);
+ --error: oklch(0.72 0.15 25);
+ --error-soft: oklch(0.3 0.05 25);
+ --warning: oklch(0.78 0.13 75);
+ --shadow: 0 18px 48px oklch(0.08 0.015 255 / 0.28);
+ }
+
+ .canvas-stage {
+ background-color: oklch(0.16 0.01 255);
+ background-image: linear-gradient(45deg, oklch(0.2 0.012 255) 25%, transparent 25%),
+ linear-gradient(-45deg, oklch(0.2 0.012 255) 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, oklch(0.2 0.012 255) 75%),
+ linear-gradient(-45deg, transparent 75%, oklch(0.2 0.012 255) 75%);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/src/vision.js b/src/vision.js
new file mode 100644
index 0000000..e412e7d
--- /dev/null
+++ b/src/vision.js
@@ -0,0 +1,124 @@
+import { createWorker, PSM } from "tesseract.js";
+import { normalizeOcrText, parseFieldValue } from "./parser.js";
+
+let cvPromise;
+let workerPromise;
+let progressListener = () => {};
+
+export function onOcrProgress(listener) {
+ progressListener = listener;
+}
+
+async function getOpenCv() {
+ if (!cvPromise) {
+ cvPromise = import("@techstark/opencv-js").then(async ({ default: module }) => {
+ const cv = module instanceof Promise ? await module : module;
+
+ if (cv.Mat) return cv;
+
+ await new Promise((resolve) => {
+ cv.onRuntimeInitialized = resolve;
+ });
+ return cv;
+ });
+ }
+
+ return cvPromise;
+}
+
+async function getOcrWorker() {
+ if (!workerPromise) {
+ workerPromise = createWorker("eng", 1, {
+ logger: (message) => progressListener(message),
+ });
+ }
+
+ return workerPromise;
+}
+
+export async function findAnchor(sourceCanvas, templateCanvas) {
+ if (
+ templateCanvas.width > sourceCanvas.width ||
+ templateCanvas.height > sourceCanvas.height
+ ) {
+ return { x: 0, y: 0, score: 0 };
+ }
+
+ const cv = await getOpenCv();
+ const source = cv.imread(sourceCanvas);
+ const template = cv.imread(templateCanvas);
+ const sourceGray = new cv.Mat();
+ const templateGray = new cv.Mat();
+ const result = new cv.Mat();
+
+ try {
+ cv.cvtColor(source, sourceGray, cv.COLOR_RGBA2GRAY);
+ cv.cvtColor(template, templateGray, cv.COLOR_RGBA2GRAY);
+ cv.matchTemplate(sourceGray, templateGray, result, cv.TM_CCOEFF_NORMED);
+ const match = cv.minMaxLoc(result);
+
+ return {
+ x: match.maxLoc.x,
+ y: match.maxLoc.y,
+ score: Number.isFinite(match.maxVal) ? match.maxVal : 0,
+ };
+ } finally {
+ source.delete();
+ template.delete();
+ sourceGray.delete();
+ templateGray.delete();
+ result.delete();
+ }
+}
+
+function prepareOcrCrop(sourceCanvas, rect) {
+ const scale = 3;
+ const canvas = document.createElement("canvas");
+ canvas.width = rect.width * scale;
+ canvas.height = rect.height * scale;
+
+ const context = canvas.getContext("2d", { willReadFrequently: true });
+ context.imageSmoothingEnabled = false;
+ context.filter = "grayscale(1) contrast(2)";
+ context.drawImage(
+ sourceCanvas,
+ rect.x,
+ rect.y,
+ rect.width,
+ rect.height,
+ 0,
+ 0,
+ canvas.width,
+ canvas.height,
+ );
+
+ return canvas;
+}
+
+export async function recognizeField(sourceCanvas, rect, type) {
+ const worker = await getOcrWorker();
+ const numbersOnly = type === "price" || type === "quantity";
+
+ await worker.setParameters({
+ tessedit_pageseg_mode: PSM.SINGLE_LINE,
+ tessedit_char_whitelist: numbersOnly ? "0123456789,. " : "",
+ preserve_interword_spaces: "1",
+ });
+
+ const image = prepareOcrCrop(sourceCanvas, rect);
+ const { data } = await worker.recognize(image);
+ const text = normalizeOcrText(data.text);
+
+ return {
+ text,
+ value: parseFieldValue(type, text),
+ confidence: Math.round(data.confidence ?? 0),
+ };
+}
+
+export async function terminateVision() {
+ if (!workerPromise) return;
+ const worker = await workerPromise;
+ await worker.terminate();
+ workerPromise = null;
+}
diff --git a/test/parser.test.js b/test/parser.test.js
new file mode 100644
index 0000000..d500849
--- /dev/null
+++ b/test/parser.test.js
@@ -0,0 +1,50 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ isCalibrationReady,
+ isRectInside,
+ parseFieldValue,
+ rectFromPoints,
+ resolveFieldRect,
+} from "../src/parser.js";
+
+test("normalizes a reverse drag into a positive rectangle", () => {
+ assert.deepEqual(rectFromPoints({ x: 140, y: 90 }, { x: 20, y: 30 }), {
+ x: 20,
+ y: 30,
+ width: 120,
+ height: 60,
+ });
+});
+
+test("moves calibrated fields together with the detected anchor", () => {
+ assert.deepEqual(
+ resolveFieldRect(
+ { x: 400, y: 250 },
+ { offsetX: 32, offsetY: 80, width: 180, height: 24 },
+ ),
+ { x: 432, y: 330, width: 180, height: 24 },
+ );
+});
+
+test("parses formatted prices and rejects empty numeric OCR", () => {
+ assert.equal(parseFieldValue("price", "1 250,000 adena"), 1250000);
+ assert.equal(parseFieldValue("quantity", "not found"), null);
+ assert.equal(parseFieldValue("item", " Soulshot: S-grade\n"), "Soulshot: S-grade");
+});
+
+test("requires an anchor image and at least one field", () => {
+ assert.equal(isCalibrationReady({ anchor: null, fields: [] }), false);
+ assert.equal(
+ isCalibrationReady({
+ anchor: { image: "data:image/png;base64,x", width: 20, height: 10 },
+ fields: [{ id: "price" }],
+ }),
+ true,
+ );
+});
+
+test("detects OCR rectangles outside the captured frame", () => {
+ assert.equal(isRectInside({ x: 10, y: 10, width: 20, height: 20 }, 100, 100), true);
+ assert.equal(isRectInside({ x: 90, y: 10, width: 20, height: 20 }, 100, 100), false);
+});