From 9ba0825bcd435055eccc2e2a57c66cd44e3b637f Mon Sep 17 00:00:00 2001 From: robin Date: Sun, 18 Jan 2026 20:38:48 +0100 Subject: [PATCH] feat: Add Spotify-style backdrop, Cascade OCR, Smart Scan Flow & OCR Dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BottleGrid: Implement blurred backdrop effect for bottle cards - Cascade OCR: TextDetector → RegEx → Fuzzy Match → window.ai pipeline - Smart Scan: Native OCR for Android, Live Text fallback for iOS - OCR Dashboard: Admin page at /admin/ocr-logs with stats and scan history - Features: Add feature flags in src/config/features.ts - SQL: Add ocr_logs table migration - Services: Update analyze-bottle to use OpenRouter, add save-ocr-log --- Logs/cpu.log | 6 + add_flavor_radar_to_tastings.sql | 5 + package.json | 1 + pnpm-lock.yaml | 563 ++++++++++++++++++ public/bg-processor.worker.js | 123 ++++ security-report.txt | 99 +++ sql/create_ocr_logs.sql | 78 +++ sql/migrate_blind_tasting.sql | 36 ++ src/app/actions/enrich-data.ts | 75 +-- src/app/actions/scanner.ts | 154 ++--- src/app/admin/ocr-logs/page.tsx | 255 ++++++++ src/app/admin/page.tsx | 6 + src/app/layout.tsx | 2 + src/app/sessions/[id]/page.tsx | 476 ++++++++++----- src/components/ActiveSessionBanner.tsx | 81 ++- src/components/BackgroundRemovalHandler.tsx | 66 ++ src/components/BottleDetails.tsx | 45 +- src/components/BottleGrid.tsx | 118 ++-- src/components/CameraCapture.tsx | 17 +- src/components/EditBottleForm.tsx | 32 +- src/components/FlavorRadar.tsx | 65 ++ src/components/NativeOCRScanner.tsx | 277 +++++++++ src/components/ScanAndTasteFlow.tsx | 17 +- src/components/SessionABVCurve.tsx | 190 +++--- src/components/SessionList.tsx | 83 +-- src/components/SessionTimeline.tsx | 60 +- src/components/TastingEditor.tsx | 283 ++++++--- src/components/TastingList.tsx | 77 ++- src/config/features.ts | 13 + src/hooks/useImageProcessor.ts | 152 +++++ src/hooks/useScanFlow.ts | 196 ++++++ src/hooks/useScanner.ts | 10 +- src/lib/ai-prompts.ts | 35 +- src/lib/db.ts | 6 +- src/lib/gemini.ts | 14 - src/lib/openrouter.ts | 7 +- ...istral.ts => analyze-bottle-openrouter.ts} | 54 +- src/services/cascade-ocr.ts | 384 ++++++++++++ src/services/extract-flavor-profile.ts | 76 +++ src/services/magic-scan.ts | 8 +- src/services/save-ocr-log.ts | 176 ++++++ src/services/save-tasting.ts | 26 + src/types/text-detector.d.ts | 34 ++ src/types/whisky.ts | 34 +- src/workers/bg-processor.worker.ts | 98 +++ tsconfig.tsbuildinfo | 2 +- 46 files changed, 3874 insertions(+), 741 deletions(-) create mode 100644 Logs/cpu.log create mode 100644 add_flavor_radar_to_tastings.sql create mode 100644 public/bg-processor.worker.js create mode 100644 security-report.txt create mode 100644 sql/create_ocr_logs.sql create mode 100644 sql/migrate_blind_tasting.sql create mode 100644 src/app/admin/ocr-logs/page.tsx create mode 100644 src/components/BackgroundRemovalHandler.tsx create mode 100644 src/components/FlavorRadar.tsx create mode 100644 src/components/NativeOCRScanner.tsx create mode 100644 src/config/features.ts create mode 100644 src/hooks/useImageProcessor.ts create mode 100644 src/hooks/useScanFlow.ts delete mode 100644 src/lib/gemini.ts rename src/services/{analyze-bottle-mistral.ts => analyze-bottle-openrouter.ts} (77%) create mode 100644 src/services/cascade-ocr.ts create mode 100644 src/services/extract-flavor-profile.ts create mode 100644 src/services/save-ocr-log.ts create mode 100644 src/types/text-detector.d.ts create mode 100644 src/workers/bg-processor.worker.ts diff --git a/Logs/cpu.log b/Logs/cpu.log new file mode 100644 index 0000000..b5c9457 --- /dev/null +++ b/Logs/cpu.log @@ -0,0 +1,6 @@ +1/6 22:21:38.843 vendor: AuthenticAMD +1/6 22:21:38.843 branding: AMD Ryzen 7 5700X3D 8-Core Processor +1/6 22:21:38.843 features: lahf64 cmpxchg16b sse sse2 sse3 ssse3 sse41 sse42 avx avx2 aesni clmul sha rdrand +1/6 22:21:38.843 sockets: 1 +1/6 22:21:38.843 cores: 8 +1/6 22:21:38.843 threads: 16 diff --git a/add_flavor_radar_to_tastings.sql b/add_flavor_radar_to_tastings.sql new file mode 100644 index 0000000..2ae7ac6 --- /dev/null +++ b/add_flavor_radar_to_tastings.sql @@ -0,0 +1,5 @@ +-- Add flavor_profile column to tastings table +ALTER TABLE public.tastings +ADD COLUMN IF NOT EXISTS flavor_profile JSONB; + +COMMENT ON COLUMN public.tastings.flavor_profile IS 'Stores radar chart scores for smoky, fruity, spicy, sweet, and floral (0-100).'; diff --git a/package.json b/package.json index 69803e2..7787b8b 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@supabase/ssr": "^0.5.2", "@supabase/supabase-js": "^2.47.10", "@tanstack/react-query": "^5.62.7", + "@xenova/transformers": "^2.17.2", "ai": "^5.0.116", "browser-image-compression": "^2.0.2", "canvas-confetti": "^1.9.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f900c6..1991c23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@tanstack/react-query': specifier: ^5.62.7 version: 5.90.12(react@19.2.3) + '@xenova/transformers': + specifier: ^2.17.2 + version: 2.17.2 ai: specifier: ^5.0.116 version: 5.0.116(zod@3.25.76) @@ -491,6 +494,10 @@ packages: resolution: {integrity: sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==} engines: {node: '>=18.0.0'} + '@huggingface/jinja@0.2.2': + resolution: {integrity: sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==} + engines: {node: '>=18'} + '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} @@ -742,6 +749,36 @@ packages: engines: {node: '>=18'} hasBin: true + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@reduxjs/toolkit@2.11.2': resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} peerDependencies: @@ -998,6 +1035,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/long@4.0.2': + resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} + '@types/node@20.19.27': resolution: {integrity: sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==} @@ -1217,6 +1257,9 @@ packages: '@vitest/utils@4.0.16': resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==} + '@xenova/transformers@2.17.2': + resolution: {integrity: sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1334,9 +1377,58 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + b4a@1.7.3: + resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.5.2: + resolution: {integrity: sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.6.2: + resolution: {integrity: sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.7.0: + resolution: {integrity: sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==} + peerDependencies: + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.3.2: + resolution: {integrity: sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.9.9: resolution: {integrity: sha512-V8fbOCSeOFvlDj7LLChUcqbZrdKD9RU/VR260piF1790vT0mfLSwGc/Qzxv3IqiTukOpNtItePa0HBpMAj7MDg==} hasBin: true @@ -1348,6 +1440,9 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bmp-js@0.1.0: resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==} @@ -1369,6 +1464,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1407,6 +1505,9 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -1421,6 +1522,13 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1544,6 +1652,14 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1603,6 +1719,9 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -1779,10 +1898,17 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1790,6 +1916,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -1832,6 +1961,9 @@ packages: resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} engines: {node: ^10.12.0 || >=12.0.0} + flatbuffers@1.12.0: + resolution: {integrity: sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==} + flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} @@ -1856,6 +1988,9 @@ packages: react-dom: optional: true + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -1906,6 +2041,9 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1937,6 +2075,9 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + guid-typescript@1.0.9: + resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -1996,6 +2137,9 @@ packages: idb-keyval@6.2.2: resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2029,6 +2173,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -2041,6 +2188,9 @@ packages: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + is-async-function@2.1.1: resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} engines: {node: '>= 0.4'} @@ -2242,6 +2392,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + long@4.0.0: + resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -2280,6 +2433,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -2294,6 +2451,9 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + motion-dom@12.23.23: resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==} @@ -2316,6 +2476,9 @@ packages: engines: {node: ^18 || >=20} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -2345,6 +2508,13 @@ packages: sass: optional: true + node-abi@3.86.0: + resolution: {integrity: sha512-sn9Et4N3ynsetj3spsZR729DVlGH6iBG4RiDMV7HEp3guyOW6W3S0unGpLDxT50mXortGUMax/ykUNQXdqc/Xg==} + engines: {node: '>=10'} + + node-addon-api@6.1.0: + resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -2403,6 +2573,19 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onnx-proto@4.0.4: + resolution: {integrity: sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA==} + + onnxruntime-common@1.14.0: + resolution: {integrity: sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==} + + onnxruntime-node@1.14.0: + resolution: {integrity: sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==} + os: [win32, darwin, linux] + + onnxruntime-web@1.14.0: + resolution: {integrity: sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw==} + openai@6.15.0: resolution: {integrity: sha512-F1Lvs5BoVvmZtzkUEVyh8mDQPPFolq4F+xdsx/DO8Hee8YF3IGAlZqUIsF+DVGhqf4aU0a3bTghsxB6OIsRy1g==} hasBin: true @@ -2479,6 +2662,9 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + playwright-core@1.57.0: resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} engines: {node: '>=18'} @@ -2544,6 +2730,11 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2555,6 +2746,13 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + protobufjs@6.11.4: + resolution: {integrity: sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==} + hasBin: true + + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2565,6 +2763,10 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-dom@19.2.3: resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} peerDependencies: @@ -2604,6 +2806,10 @@ packages: read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -2687,6 +2893,9 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -2729,6 +2938,10 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + sharp@0.32.6: + resolution: {integrity: sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==} + engines: {node: '>=14.15.0'} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2760,6 +2973,15 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2777,6 +2999,9 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + streamx@2.23.0: + resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -2800,6 +3025,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -2812,6 +3040,10 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -2850,12 +3082,28 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-fs@3.1.1: + resolution: {integrity: sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + tesseract.js-core@7.0.0: resolution: {integrity: sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==} tesseract.js@7.0.0: resolution: {integrity: sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==} + text-decoder@1.2.3: + resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -2921,6 +3169,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3478,6 +3729,8 @@ snapshots: '@google/generative-ai@0.24.1': {} + '@huggingface/jinja@0.2.2': {} + '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -3667,6 +3920,29 @@ snapshots: dependencies: playwright: 1.57.0 + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1))(react@19.2.3)': dependencies: '@standard-schema/spec': 1.1.0 @@ -3903,6 +4179,8 @@ snapshots: '@types/json5@0.0.29': {} + '@types/long@4.0.2': {} + '@types/node@20.19.27': dependencies: undici-types: 6.21.0 @@ -4130,6 +4408,18 @@ snapshots: '@vitest/pretty-format': 4.0.16 tinyrainbow: 3.0.3 + '@xenova/transformers@2.17.2': + dependencies: + '@huggingface/jinja': 0.2.2 + onnxruntime-web: 1.14.0 + sharp: 0.32.6 + optionalDependencies: + onnxruntime-node: 1.14.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -4268,8 +4558,49 @@ snapshots: axobject-query@4.1.0: {} + b4a@1.7.3: {} + balanced-match@1.0.2: {} + bare-events@2.8.2: {} + + bare-fs@4.5.2: + dependencies: + bare-events: 2.8.2 + bare-path: 3.0.0 + bare-stream: 2.7.0(bare-events@2.8.2) + bare-url: 2.3.2 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + optional: true + + bare-os@3.6.2: + optional: true + + bare-path@3.0.0: + dependencies: + bare-os: 3.6.2 + optional: true + + bare-stream@2.7.0(bare-events@2.8.2): + dependencies: + streamx: 2.23.0 + optionalDependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + optional: true + + bare-url@2.3.2: + dependencies: + bare-path: 3.0.0 + optional: true + + base64-js@1.5.1: {} + baseline-browser-mapping@2.9.9: {} bidi-js@1.0.3: @@ -4278,6 +4609,12 @@ snapshots: binary-extensions@2.3.0: {} + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + bmp-js@0.1.0: {} brace-expansion@1.1.12: @@ -4305,6 +4642,11 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -4349,6 +4691,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chownr@1.1.4: {} + client-only@0.0.1: {} clsx@2.1.1: {} @@ -4359,6 +4703,16 @@ snapshots: color-name@1.1.4: {} + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + commander@4.1.1: {} concat-map@0.0.1: {} @@ -4465,6 +4819,12 @@ snapshots: decimal.js@10.6.0: {} + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -4517,6 +4877,10 @@ snapshots: emoji-regex@9.2.2: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + entities@6.0.1: {} es-abstract@1.24.1: @@ -4872,12 +5236,22 @@ snapshots: eventemitter3@5.0.1: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + eventsource-parser@3.0.6: {} + expand-template@2.0.3: {} + expect-type@1.3.0: {} fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4925,6 +5299,8 @@ snapshots: keyv: 4.5.4 rimraf: 3.0.2 + flatbuffers@1.12.0: {} + flatted@3.3.3: {} for-each@0.3.5: @@ -4942,6 +5318,8 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + fs-constants@1.0.0: {} + fs.realpath@1.0.0: {} fsevents@2.3.2: @@ -4997,6 +5375,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + github-from-package@0.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -5029,6 +5409,8 @@ snapshots: graphemer@1.4.0: {} + guid-typescript@1.0.9: {} + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -5085,6 +5467,8 @@ snapshots: idb-keyval@6.2.2: {} + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -5109,6 +5493,8 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -5123,6 +5509,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-arrayish@0.3.4: {} + is-async-function@2.1.1: dependencies: async-function: 1.0.0 @@ -5335,6 +5723,8 @@ snapshots: lodash.merge@4.6.2: {} + long@4.0.0: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -5366,6 +5756,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mimic-response@3.1.0: {} + min-indent@1.0.1: {} minimatch@3.1.2: @@ -5378,6 +5770,8 @@ snapshots: minimist@1.2.8: {} + mkdirp-classic@0.5.3: {} + motion-dom@12.23.23: dependencies: motion-utils: 12.23.6 @@ -5396,6 +5790,8 @@ snapshots: nanoid@5.1.6: {} + napi-build-utils@2.0.0: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -5426,6 +5822,12 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-abi@3.86.0: + dependencies: + semver: 7.7.3 + + node-addon-api@6.1.0: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -5484,6 +5886,26 @@ snapshots: dependencies: wrappy: 1.0.2 + onnx-proto@4.0.4: + dependencies: + protobufjs: 6.11.4 + + onnxruntime-common@1.14.0: {} + + onnxruntime-node@1.14.0: + dependencies: + onnxruntime-common: 1.14.0 + optional: true + + onnxruntime-web@1.14.0: + dependencies: + flatbuffers: 1.12.0 + guid-typescript: 1.0.9 + long: 4.0.0 + onnx-proto: 4.0.4 + onnxruntime-common: 1.14.0 + platform: 1.3.6 + openai@6.15.0(ws@8.18.3)(zod@3.25.76): optionalDependencies: ws: 8.18.3 @@ -5542,6 +5964,8 @@ snapshots: pirates@4.0.7: {} + platform@1.3.6: {} + playwright-core@1.57.0: {} playwright@1.57.0: @@ -5595,6 +6019,21 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.86.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} pretty-format@27.5.1: @@ -5609,12 +6048,40 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + protobufjs@6.11.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/long': 4.0.2 + '@types/node': 20.19.27 + long: 4.0.0 + + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} qr.js@0.0.0: {} queue-microtask@1.2.3: {} + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-dom@19.2.3(react@19.2.3): dependencies: react: 19.2.3 @@ -5647,6 +6114,12 @@ snapshots: dependencies: pify: 2.3.0 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -5772,6 +6245,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -5821,6 +6296,21 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + sharp@0.32.6: + dependencies: + color: 4.2.3 + detect-libc: 2.1.2 + node-addon-api: 6.1.0 + prebuild-install: 7.1.3 + semver: 7.7.3 + simple-get: 4.0.1 + tar-fs: 3.1.1 + tunnel-agent: 0.6.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -5888,6 +6378,18 @@ snapshots: siginfo@2.0.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + source-map-js@1.2.1: {} stable-hash@0.0.5: {} @@ -5901,6 +6403,15 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + streamx@2.23.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.3 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.8 @@ -5951,6 +6462,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -5961,6 +6476,8 @@ snapshots: dependencies: min-indent: 1.0.1 + strip-json-comments@2.0.1: {} + strip-json-comments@3.1.1: {} styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.3): @@ -6016,6 +6533,42 @@ snapshots: - tsx - yaml + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-fs@3.1.1: + dependencies: + pump: 3.0.3 + tar-stream: 3.1.7 + optionalDependencies: + bare-fs: 4.5.2 + bare-path: 3.0.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar-stream@3.1.7: + dependencies: + b4a: 1.7.3 + fast-fifo: 1.3.2 + streamx: 2.23.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + tesseract.js-core@7.0.0: {} tesseract.js@7.0.0: @@ -6032,6 +6585,12 @@ snapshots: transitivePeerDependencies: - encoding + text-decoder@1.2.3: + dependencies: + b4a: 1.7.3 + transitivePeerDependencies: + - react-native-b4a + text-table@0.2.0: {} thenify-all@1.6.0: @@ -6090,6 +6649,10 @@ snapshots: tslib@2.8.1: {} + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/public/bg-processor.worker.js b/public/bg-processor.worker.js new file mode 100644 index 0000000..97b8706 --- /dev/null +++ b/public/bg-processor.worker.js @@ -0,0 +1,123 @@ +// Background Removal Worker using briaai/RMBG-1.4 +// Using @huggingface/transformers v3 + +import { AutoModel, AutoProcessor, RawImage, env } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.5.1'; + +console.log('[BG-Processor Worker] Script loaded from /public'); + +// Configuration +env.allowLocalModels = false; +env.useBrowserCache = true; +// Force WASM backend (more compatible) +env.backends.onnx.wasm.proxy = false; + +let model = null; +let processor = null; + +/** + * Load the RMBG-1.4 model (WASM only for compatibility) + */ +const loadModel = async () => { + if (!model) { + console.log('[BG-Processor Worker] Loading briaai/RMBG-1.4 model (WASM)...'); + model = await AutoModel.from_pretrained('briaai/RMBG-1.4', { + device: 'wasm', + dtype: 'fp32', + }); + processor = await AutoProcessor.from_pretrained('briaai/RMBG-1.4'); + console.log('[BG-Processor Worker] Model loaded successfully.'); + } + return { model, processor }; +}; + +/** + * Apply the alpha mask to the original image + */ +const applyMask = async (originalBlob, maskData, width, height) => { + const bitmap = await createImageBitmap(originalBlob); + + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error("No Canvas context"); + + // Draw original image + ctx.drawImage(bitmap, 0, 0); + + // Get image data + const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height); + const data = imageData.data; + + // Create mask canvas at model output size + const maskCanvas = new OffscreenCanvas(width, height); + const maskCtx = maskCanvas.getContext('2d'); + const maskImageData = maskCtx.createImageData(width, height); + + // Convert model output to grayscale image + for (let i = 0; i < maskData.length; i++) { + const val = Math.round(Math.max(0, Math.min(1, maskData[i])) * 255); + maskImageData.data[i * 4] = val; + maskImageData.data[i * 4 + 1] = val; + maskImageData.data[i * 4 + 2] = val; + maskImageData.data[i * 4 + 3] = 255; + } + maskCtx.putImageData(maskImageData, 0, 0); + + // Scale mask to original size + const scaledMaskCanvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const scaledMaskCtx = scaledMaskCanvas.getContext('2d'); + scaledMaskCtx.drawImage(maskCanvas, 0, 0, bitmap.width, bitmap.height); + const scaledMaskData = scaledMaskCtx.getImageData(0, 0, bitmap.width, bitmap.height); + + // Apply mask as alpha + for (let i = 0; i < data.length; i += 4) { + data[i + 3] = scaledMaskData.data[i]; // Use R channel as alpha + } + + ctx.putImageData(imageData, 0, 0); + return await canvas.convertToBlob({ type: 'image/png' }); +}; + +self.onmessage = async (e) => { + const { type, id, imageBlob } = e.data; + + if (type === 'ping') { + self.postMessage({ type: 'pong' }); + return; + } + + if (!imageBlob) return; + + console.log(`[BG-Processor Worker] Received request for ${id}`); + + try { + const { model, processor } = await loadModel(); + + // Convert blob to RawImage + const url = URL.createObjectURL(imageBlob); + const image = await RawImage.fromURL(url); + URL.revokeObjectURL(url); + + console.log('[BG-Processor Worker] Running inference...'); + + // Process image + const { pixel_values } = await processor(image); + + // Run model + const { output } = await model({ input: pixel_values }); + + // Get mask data - output is a Tensor + const maskData = output.data; + const [batch, channels, height, width] = output.dims; + + console.log(`[BG-Processor Worker] Mask dims: ${width}x${height}`); + console.log('[BG-Processor Worker] Applying mask...'); + + const processedBlob = await applyMask(imageBlob, maskData, width, height); + + self.postMessage({ id, status: 'success', blob: processedBlob }); + console.log(`[BG-Processor Worker] Successfully processed ${id}`); + } catch (err) { + console.error(`[BG-Processor Worker] Processing Error (${id}):`, err); + self.postMessage({ id, status: 'error', error: err.message }); + } +}; diff --git a/security-report.txt b/security-report.txt new file mode 100644 index 0000000..10e1784 --- /dev/null +++ b/security-report.txt @@ -0,0 +1,99 @@ + + +┌──────────────────┐ +│ 15 Code Findings │ +└──────────────────┘ + + public/sw.js + ❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring + Detected string concatenation with a non-literal variable in a util.format / console.log function. + If an attacker injects a format specifier in the string, it will forge the log message. Try to use + constant values for the format string. + Details: https://sg.run/7Y5R + + 75┆ console.error(`⚠️ PWA: Pre-cache failed for ${url}:`, error); + ⋮┆---------------------------------------- + 174┆ console.error(`[SW] Failed to fetch ${url.pathname}:`, error); + + scripts/scrape-distillery-tags.ts + ❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring + Detected string concatenation with a non-literal variable in a util.format / console.log function. + If an attacker injects a format specifier in the string, it will forge the log message. Try to use + constant values for the format string. + Details: https://sg.run/7Y5R + + 107┆ console.error(`❌ API Error for ${name}: ${response.status}`, data.error || data); + ⋮┆---------------------------------------- + 116┆ console.error(`⚠️ OpenRouter Error for ${name}:`, data.error.message); + ⋮┆---------------------------------------- + 119┆ console.error(`⚠️ No content returned for ${name}. Full response:`, JSON.stringify(data, + null, 2)); + ⋮┆---------------------------------------- + 125┆ console.error(`❌ Fetch Exception for ${name}:`, error); + + src/context/AuthContext.tsx + ❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring + Detected string concatenation with a non-literal variable in a util.format / console.log function. + If an attacker injects a format specifier in the string, it will forge the log message. Try to use + constant values for the format string. + Details: https://sg.run/7Y5R + + 40┆ console.log(`[AuthContext] event: ${event}`, { + + src/hooks/useScanner.ts + ❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring + Detected string concatenation with a non-literal variable in a util.format / console.log function. + If an attacker injects a format specifier in the string, it will forge the log message. Try to use + constant values for the format string. + Details: https://sg.run/7Y5R + + 157┆ console.log(`[useScanner] ${providerUsed} complete:`, cloudResult); + ⋮┆---------------------------------------- + 186┆ console.warn(`[useScanner] ${providerUsed} failed:`, cloudResponse.error); + + src/i18n/I18nContext.tsx + ❯❱ javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop.prototype-pollution-loop + Possibility of prototype polluting function detected. By adding or modifying attributes of an object + prototype, it is possible to create attributes that exist on every object, or replace critical + attributes with malicious ones. This can be problematic if the software depends on existence or non- + existence of certain attributes, or uses pre-defined attributes of object prototype (such as + hasOwnProperty, toString or valueOf). Possible mitigations might be: freezing the object prototype, + using an object without prototypes (via Object.create(null) ), blocking modifications of attributes + that resolve to object prototype, using Map instead of object. + Details: https://sg.run/w1DB + + 54┆ current = current[key]; + + src/lib/distillery-matcher.ts + ❯❱ javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp + RegExp() called with a `distillery` function argument, this might allow an attacker to cause a + Regular Expression Denial-of-Service (ReDoS) within your application as RegExP blocks the main + thread. For this reason, it is recommended to use hardcoded regexes instead. If your regex is run on + user-controlled input, consider performing input validation or use a regex checking/sanitization + library such as https://www.npmjs.com/package/recheck to verify that the regex does not appear + vulnerable to ReDoS. + Details: https://sg.run/gr65 + + 154┆ const regex = new RegExp(`^${escaped}\\s*[-–—:]?\\s*`, 'i'); + ⋮┆---------------------------------------- + 161┆ const anywhereRegex = new RegExp(`\\b${escaped}\\b\\s*[-–—:]?\\s*`, 'i'); + + src/services/bulk-scan.ts + ❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring + Detected string concatenation with a non-literal variable in a util.format / console.log function. + If an attacker injects a format specifier in the string, it will forge the log message. Try to use + constant values for the format string. + Details: https://sg.run/7Y5R + + 211┆ console.error(`Analysis failed for bottle ${bottleId}:`, error); + + src/services/tags.ts + ❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring + Detected string concatenation with a non-literal variable in a util.format / console.log function. + If an attacker injects a format specifier in the string, it will forge the log message. Try to use + constant values for the format string. + Details: https://sg.run/7Y5R + + 33┆ console.error(`Error fetching tags for ${category}:`, error); + ⋮┆---------------------------------------- + 39┆ console.error(`Exception in getTagsByCategory for ${category}:`, err); diff --git a/sql/create_ocr_logs.sql b/sql/create_ocr_logs.sql new file mode 100644 index 0000000..afdd8f0 --- /dev/null +++ b/sql/create_ocr_logs.sql @@ -0,0 +1,78 @@ +-- OCR Logs Table for storing cascade OCR results +-- This allows admins to view OCR recognition results from mobile devices + +CREATE TABLE IF NOT EXISTS ocr_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + bottle_id UUID REFERENCES bottles(id) ON DELETE SET NULL, + + -- Image data + image_url TEXT, -- URL to the scanned image + image_thumbnail TEXT, -- Base64 thumbnail for quick preview + + -- Detected fields + raw_text TEXT, -- All detected text joined + detected_texts JSONB, -- Array of individual text detections + + -- Extracted data + distillery TEXT, + distillery_source TEXT, -- 'fuzzy', 'ai', 'manual' + bottle_name TEXT, + abv DECIMAL(5,2), + age INTEGER, + vintage TEXT, + volume TEXT, + category TEXT, + + -- Meta + confidence INTEGER, -- 0-100 + device_info TEXT, -- User agent or device type + ocr_method TEXT, -- 'text_detector', 'fallback', etc. + processing_time_ms INTEGER, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- Index for efficient queries +CREATE INDEX IF NOT EXISTS idx_ocr_logs_user_id ON ocr_logs(user_id); +CREATE INDEX IF NOT EXISTS idx_ocr_logs_created_at ON ocr_logs(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_ocr_logs_distillery ON ocr_logs(distillery); + +-- RLS Policies +ALTER TABLE ocr_logs ENABLE ROW LEVEL SECURITY; + +-- Users can view their own logs +CREATE POLICY "Users can view own ocr_logs" + ON ocr_logs FOR SELECT + USING (auth.uid() = user_id); + +-- Users can insert their own logs +CREATE POLICY "Users can insert own ocr_logs" + ON ocr_logs FOR INSERT + WITH CHECK (auth.uid() = user_id); + +-- Admins can view all logs +CREATE POLICY "Admins can view all ocr_logs" + ON ocr_logs FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM admin_users + WHERE admin_users.user_id = auth.uid() + ) + ); + +-- Trigger for updated_at +CREATE OR REPLACE FUNCTION update_ocr_logs_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_ocr_logs_updated_at + BEFORE UPDATE ON ocr_logs + FOR EACH ROW + EXECUTE FUNCTION update_ocr_logs_updated_at(); diff --git a/sql/migrate_blind_tasting.sql b/sql/migrate_blind_tasting.sql new file mode 100644 index 0000000..fc3489f --- /dev/null +++ b/sql/migrate_blind_tasting.sql @@ -0,0 +1,36 @@ +-- Add Blind Tasting support to Sessions +ALTER TABLE public.tasting_sessions +ADD COLUMN IF NOT EXISTS is_blind BOOLEAN DEFAULT false, +ADD COLUMN IF NOT EXISTS is_revealed BOOLEAN DEFAULT false; + +-- Add Guessing fields to Tastings +ALTER TABLE public.tastings +ADD COLUMN IF NOT EXISTS blind_label TEXT, +ADD COLUMN IF NOT EXISTS guess_abv DECIMAL, +ADD COLUMN IF NOT EXISTS guess_age INTEGER, +ADD COLUMN IF NOT EXISTS guess_region TEXT, +ADD COLUMN IF NOT EXISTS guess_points INTEGER; + +-- Update RLS Policies for blind sessions +-- Guests should only see bottle details if NOT blind OR revealed +-- This is a complex policy update, we'll refine the existing tastings_select_policy + +DROP POLICY IF EXISTS "tastings_select_policy" ON public.tastings; +CREATE POLICY "tastings_select_policy" ON public.tastings FOR SELECT USING ( + -- You can see your own tastings + auth.uid() = user_id + OR + -- You can see tastings in a session you participate in + EXISTS ( + SELECT 1 + FROM public.session_participants sp + JOIN public.buddies b ON b.id = sp.buddy_id + WHERE sp.session_id = public.tastings.session_id + AND b.buddy_profile_id = auth.uid() + ) +); + +-- Note: The logic for hiding bottle details will be handled in the UI/API layer +-- as the RLS here still needs to allow access to the tasting record itself. +-- Hiding 'bottle_id' content for blind tastings will be done in the frontend +-- based on session.is_blind and session.is_revealed. diff --git a/src/app/actions/enrich-data.ts b/src/app/actions/enrich-data.ts index a33b8d4..7e2d69a 100644 --- a/src/app/actions/enrich-data.ts +++ b/src/app/actions/enrich-data.ts @@ -1,6 +1,5 @@ 'use server'; -import { GoogleGenerativeAI, SchemaType, HarmCategory, HarmBlockThreshold } from '@google/generative-ai'; import { createClient } from '@/lib/supabase/server'; import { trackApiUsage } from '@/services/track-api-usage'; import { deductCredits } from '@/services/credit-service'; @@ -8,32 +7,6 @@ import { getAllSystemTags } from '@/services/tags'; import { getAIProvider, getOpenRouterClient, OPENROUTER_PROVIDER_PREFERENCES } from '@/lib/openrouter'; import { getEnrichmentCache, saveEnrichmentCache, incrementCacheHit } from '@/services/cache-enrichment'; -// Native Schema Definition for Enrichment Data -const enrichmentSchema = { - description: "Sensory profile and search metadata for whisky", - type: SchemaType.OBJECT as const, - properties: { - suggested_tags: { - type: SchemaType.ARRAY, - description: "Array of suggested aroma/taste tags from the available system tags", - items: { type: SchemaType.STRING }, - nullable: true - }, - suggested_custom_tags: { - type: SchemaType.ARRAY, - description: "Array of custom dominant notes not in the system tags", - items: { type: SchemaType.STRING }, - nullable: true - }, - search_string: { - type: SchemaType.STRING, - description: "Optimized search query for Whiskybase discovery", - nullable: true - } - }, - required: [], -}; - const ENRICHMENT_MODEL = 'google/gemma-3-27b-it'; /** @@ -107,46 +80,11 @@ async function enrichWithOpenRouter(instruction: string): Promise<{ data: any; a throw lastError || new Error('OpenRouter enrichment failed after retries'); } -/** - * Enrich with Gemini - */ -async function enrichWithGemini(instruction: string): Promise<{ data: any; apiTime: number; responseText: string }> { - const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!); - const model = genAI.getGenerativeModel({ - model: 'gemini-2.5-flash', - generationConfig: { - responseMimeType: "application/json", - responseSchema: enrichmentSchema as any, - temperature: 0.3, - }, - safetySettings: [ - { category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_NONE }, - { category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_NONE }, - { category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_NONE }, - { category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_NONE }, - ] as any, - }); - - const startApi = performance.now(); - const result = await model.generateContent(instruction); - const endApi = performance.now(); - - const responseText = result.response.text(); - return { - data: JSON.parse(responseText), - apiTime: endApi - startApi, - responseText: responseText - }; -} - export async function enrichData(name: string, distillery: string, availableTags?: string, language: string = 'de') { const provider = getAIProvider(); - // Check API key based on provider - if (provider === 'gemini' && !process.env.GEMINI_API_KEY) { - return { success: false, error: 'GEMINI_API_KEY is not configured.' }; - } - if (provider === 'openrouter' && !process.env.OPENROUTER_API_KEY) { + // Check API key + if (!process.env.OPENROUTER_API_KEY) { return { success: false, error: 'OPENROUTER_API_KEY is not configured.' }; } @@ -203,13 +141,8 @@ Instructions: 3. Provide a clean "search_string" for Whiskybase (e.g., "Distillery Name Age").`; console.log(`[EnrichData] Using provider: ${provider}`); - let result: { data: any; apiTime: number; responseText: string }; - if (provider === 'openrouter') { - result = await enrichWithOpenRouter(instruction); - } else { - result = await enrichWithGemini(instruction); - } + const result = await enrichWithOpenRouter(instruction); console.log('[EnrichData] Response:', result.data); @@ -229,7 +162,7 @@ Instructions: endpoint: `enrichData_${provider}`, success: true, provider: provider, - model: provider === 'openrouter' ? ENRICHMENT_MODEL : 'gemini-2.5-flash', + model: ENRICHMENT_MODEL, responseText: result.responseText }); diff --git a/src/app/actions/scanner.ts b/src/app/actions/scanner.ts index 40cb55f..7cd59e3 100644 --- a/src/app/actions/scanner.ts +++ b/src/app/actions/scanner.ts @@ -1,6 +1,5 @@ 'use server'; -import { GoogleGenerativeAI, SchemaType, HarmCategory, HarmBlockThreshold } from '@google/generative-ai'; import { BottleMetadataSchema, BottleMetadata } from '@/types/whisky'; import { createClient } from '@/lib/supabase/server'; import { trackApiUsage } from '@/services/track-api-usage'; @@ -9,30 +8,6 @@ import { getAIProvider, getOpenRouterClient, OPENROUTER_VISION_MODEL, OPENROUTER import { normalizeWhiskyData } from '@/lib/distillery-matcher'; import { formatWhiskyName } from '@/utils/formatWhiskyName'; import { createHash } from 'crypto'; -import sharp from 'sharp'; - -// Schema for AI extraction -const visionSchema = { - description: "Whisky bottle label metadata extracted from image", - type: SchemaType.OBJECT as const, - properties: { - name: { type: SchemaType.STRING, description: "Full whisky name (constructed)", nullable: false }, - distillery: { type: SchemaType.STRING, description: "Distillery name", nullable: true }, - bottler: { type: SchemaType.STRING, description: "Independent bottler if applicable", nullable: true }, - series: { type: SchemaType.STRING, description: "Whisky series or collection (e.g. Cadenhead's Natural Strength)", nullable: true }, - category: { type: SchemaType.STRING, description: "Whisky category (Single Malt, Blended, Bourbon, etc.)", nullable: true }, - abv: { type: SchemaType.NUMBER, description: "Alcohol by volume percentage", nullable: true }, - age: { type: SchemaType.NUMBER, description: "Age statement in years", nullable: true }, - vintage: { type: SchemaType.STRING, description: "Vintage/distillation year", nullable: true }, - cask_type: { type: SchemaType.STRING, description: "Cask type (Sherry, Bourbon, Port, etc.)", nullable: true }, - distilled_at: { type: SchemaType.STRING, description: "Distillation date", nullable: true }, - bottled_at: { type: SchemaType.STRING, description: "Bottling date", nullable: true }, - batch_info: { type: SchemaType.STRING, description: "Batch or cask number", nullable: true }, - is_whisky: { type: SchemaType.BOOLEAN, description: "Whether this is a whisky product", nullable: false }, - confidence: { type: SchemaType.NUMBER, description: "Confidence score 0-1", nullable: false }, - }, - required: ["name", "is_whisky", "confidence"], -}; const VISION_PROMPT = `ROLE: Senior Whisky Database Curator. @@ -68,13 +43,11 @@ OUTPUT SCHEMA (Strict JSON): "confidence": number }`; -const GEMINI_MODEL = 'gemini-2.5-flash'; - export interface ScannerResult { success: boolean; data?: BottleMetadata; error?: string; - provider?: 'gemini' | 'openrouter'; + provider?: 'openrouter'; perf?: { imagePrep?: number; apiCall: number; @@ -183,86 +156,57 @@ export async function analyzeBottleLabel(imageBase64: string): Promise setTimeout(resolve, delay)); - continue; - } - throw err; - } - } - - if (!response) throw lastError || new Error('OpenRouter response failed after retries'); - - const content = response.choices[0]?.message?.content || '{}'; - let jsonStr = content; - const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/) || content.match(/\{[\s\S]*\}/); - if (jsonMatch) { - jsonStr = jsonMatch[jsonMatch.length - 1].trim(); - } - aiResult = { - data: JSON.parse(jsonStr), - apiTime: performance.now() - startApi, - responseText: content - }; - } else { - const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!); - const model = genAI.getGenerativeModel({ - model: GEMINI_MODEL, - generationConfig: { - responseMimeType: "application/json", - responseSchema: visionSchema as any, + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + response = await client.chat.completions.create({ + model: OPENROUTER_VISION_MODEL, + messages: [ + { + role: 'user', + content: [ + { type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Data}` } }, + { type: 'text', text: VISION_PROMPT }, + ], + }, + ], temperature: 0.1, - }, - safetySettings: [ - { category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_NONE }, - { category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_NONE }, - { category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_NONE }, - { category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_NONE }, - ] as any, - }); - const startApi = performance.now(); - const result = await model.generateContent([ - { inlineData: { data: base64Data, mimeType } }, - { text: VISION_PROMPT }, - ]); - const responseText = result.response.text(); - aiResult = { - data: JSON.parse(responseText), - apiTime: performance.now() - startApi, - responseText: responseText - }; + max_tokens: 1024, + // @ts-ignore + provider: OPENROUTER_PROVIDER_PREFERENCES, + }); + break; // Success! + } catch (err: any) { + lastError = err; + if (err.status === 429 && attempt < maxRetries) { + const delay = Math.pow(2, attempt) * 1000; + console.warn(`[Scanner] Rate limited (429). Retrying in ${delay}ms...`); + await new Promise(resolve => setTimeout(resolve, delay)); + continue; + } + throw err; + } } + if (!response) throw lastError || new Error('OpenRouter response failed after retries'); + + const content = response.choices[0]?.message?.content || '{}'; + let jsonStr = content; + const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/) || content.match(/\{[\s\S]*\}/); + if (jsonMatch) { + jsonStr = jsonMatch[jsonMatch.length - 1].trim(); + } + aiResult = { + data: JSON.parse(jsonStr), + apiTime: performance.now() - startApi, + responseText: content + }; + // 6. Name Composition & Normalization // Use standardized helper to construct the perfect name console.log(`[Uncleaned Data]: ${JSON.stringify(aiResult.data)}`); @@ -301,7 +245,7 @@ export async function analyzeBottleLabel(imageBase64: string): Promise +
+ {/* Header */} +
+
+

OCR Dashboard

+

Mobile OCR Scan Results

+
+
+ + ← Back to Admin + +
+
+ + {/* Stats Cards */} +
+
+
+
+ +
+ Total Scans +
+
{stats.totalScans}
+
All time
+
+ +
+
+
+ +
+ Today +
+
{stats.todayScans}
+
Scans today
+
+ +
+
+
+ +
+ Avg Confidence +
+
{stats.avgConfidence}%
+
Recognition quality
+
+ +
+
+
+ +
+ Top Distillery +
+
+ {stats.topDistilleries[0]?.name || '-'} +
+
+ {stats.topDistilleries[0] ? `${stats.topDistilleries[0].count} scans` : 'No data'} +
+
+
+ + {/* Top Distilleries */} + {stats.topDistilleries.length > 0 && ( +
+

Most Scanned Distilleries

+
+ {stats.topDistilleries.map((d, i) => ( + + {d.name} ({d.count}) + + ))} +
+
+ )} + + {/* OCR Logs Grid */} +
+

Recent OCR Scans

+ + {logs.length === 0 ? ( +
+ +

No OCR scans recorded yet

+

Scans from mobile devices will appear here

+
+ ) : ( +
+ {logs.map((log: any) => ( +
+ {/* Image Preview */} +
+ {log.image_thumbnail ? ( + Scan + ) : log.image_url ? ( + Scan + ) : ( +
+ +
+ )} + + {/* Confidence Badge */} +
= 70 + ? 'bg-green-500 text-white' + : log.confidence >= 40 + ? 'bg-amber-500 text-white' + : 'bg-red-500 text-white' + }`}> + {log.confidence}% +
+
+ + {/* Detected Fields */} +
+ {log.distillery && ( +
+ + + {log.distillery} + + {log.distillery_source && ( + + {log.distillery_source} + + )} +
+ )} + + {log.bottle_name && ( +
+ {log.bottle_name} +
+ )} + +
+ {log.abv && ( + + {log.abv}% + + )} + {log.age && ( + + {log.age}y + + )} + {log.vintage && ( + + {log.vintage} + + )} + {log.volume && ( + + {log.volume} + + )} +
+
+ + {/* Raw Text (Collapsible) */} + {log.raw_text && ( +
+ + Raw Text + +
+                                                {log.raw_text}
+                                            
+
+ )} + + {/* Meta */} +
+
+ + {new Date(log.created_at).toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + })} +
+
+ {log.profiles?.username || 'Unknown'} +
+ {log.processing_time_ms && ( +
+ {log.processing_time_ms}ms +
+ )} +
+
+ ))} +
+ )} +
+
+ + ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index c54bb32..887d56e 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -93,6 +93,12 @@ export default async function AdminPage() {

API Usage Monitoring & Statistics

+ + OCR Logs + + {children} diff --git a/src/app/sessions/[id]/page.tsx b/src/app/sessions/[id]/page.tsx index 24abd8c..134561c 100644 --- a/src/app/sessions/[id]/page.tsx +++ b/src/app/sessions/[id]/page.tsx @@ -16,6 +16,7 @@ import SessionABVCurve from '@/components/SessionABVCurve'; import OfflineIndicator from '@/components/OfflineIndicator'; import BulkScanSheet from '@/components/BulkScanSheet'; import BottleSkeletonCard from '@/components/BottleSkeletonCard'; +import ScanAndTasteFlow from '@/components/ScanAndTasteFlow'; interface Buddy { id: string; @@ -34,12 +35,20 @@ interface Session { name: string; scheduled_at: string; ended_at?: string; + is_blind: boolean; + is_revealed: boolean; + user_id: string; } interface SessionTasting { id: string; rating: number; tasted_at: string; + blind_label?: string; + guess_abv?: number; + guess_age?: number; + guess_region?: string; + guess_points?: number; bottles: { id: string; name: string; @@ -57,21 +66,36 @@ interface SessionTasting { } export default function SessionDetailPage() { - const { t } = useI18n(); + const { t, locale } = useI18n(); const { id } = useParams(); const router = useRouter(); + const { activeSession, setActiveSession } = useSession(); const supabase = createClient(); + const [session, setSession] = useState(null); - const [participants, setParticipants] = useState([]); const [tastings, setTastings] = useState([]); + const [participants, setParticipants] = useState([]); const [allBuddies, setAllBuddies] = useState([]); const [isLoading, setIsLoading] = useState(true); const { user, isLoading: isAuthLoading } = useAuth(); - const { activeSession, setActiveSession } = useSession(); const [isAddingParticipant, setIsAddingParticipant] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [isClosing, setIsClosing] = useState(false); const [isBulkScanOpen, setIsBulkScanOpen] = useState(false); + const [isUpdatingBlind, setIsUpdatingBlind] = useState(false); + + // New: Direct Scan Flow + const [isScanFlowOpen, setIsScanFlowOpen] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const fileInputRef = React.useRef(null); + + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setSelectedFile(file); + setIsScanFlowOpen(true); + } + }; useEffect(() => { if (!isAuthLoading && user) { @@ -131,6 +155,11 @@ export default function SessionDetailPage() { id, rating, tasted_at, + blind_label, + guess_abv, + guess_age, + guess_region, + guess_points, bottles(id, name, distillery, image_url, abv, category, processing_status), tasting_tags(tags(name)) `) @@ -183,21 +212,67 @@ export default function SessionDetailPage() { const handleCloseSession = async () => { if (!confirm('Möchtest du diese Session wirklich abschließen?')) return; - setIsClosing(true); - const result = await closeSession(id as string); - - if (result.success) { + const { success } = await closeSession(id as string); + if (success) { if (activeSession?.id === id) { setActiveSession(null); } fetchSessionData(); - } else { - alert(result.error); } setIsClosing(false); }; + const handleToggleBlindMode = async () => { + if (!session) return; + setIsUpdatingBlind(true); + const { error } = await supabase + .from('tasting_sessions') + .update({ is_blind: !session.is_blind }) + .eq('id', id); + + if (!error) { + fetchSessionData(); + } + setIsUpdatingBlind(false); + }; + + const handleRevealBlindMode = async () => { + if (!session) return; + if (!confirm('Möchtest du alle Flaschen aufdecken?')) return; + + setIsUpdatingBlind(true); + const { error } = await supabase + .from('tasting_sessions') + .update({ is_revealed: true }) + .eq('id', id); + + if (!error) { + fetchSessionData(); + } + setIsUpdatingBlind(false); + }; + + const calculateGuessPoints = (tasting: SessionTasting) => { + let points = 0; + + // ABV Scoring (100 base - 10 per 1% dev) + if (tasting.guess_abv && tasting.bottles.abv) { + const abvDev = Math.abs(tasting.guess_abv - tasting.bottles.abv); + points += Math.max(0, 100 - (abvDev * 10)); + } + + // Age Scoring (100 base - 5 per year dev) + // Note: bottles table has 'age' as integer + const bottleAge = (tasting.bottles as any).age; + if (tasting.guess_age && bottleAge) { + const ageDev = Math.abs(tasting.guess_age - bottleAge); + points += Math.max(0, 100 - (ageDev * 5)); + } + + return Math.round(points); + }; + const handleDeleteSession = async () => { if (!confirm('Möchtest du diese Session wirklich löschen? Alle Verknüpfungen gehen verloren.')) return; @@ -233,95 +308,129 @@ export default function SessionDetailPage() { } return ( -
-
- {/* Back Button */} +
+
+ {/* Back Link & Info */}
- +
+ +
Alle Sessions - +
+ + +
- {/* Hero */} -
- {/* Visual Eyecatcher: Background Glow */} + {/* Immersive Header */} +
+ {/* Background Visuals */} +
{tastings.length > 0 && tastings[0].bottles.image_url && ( -
+
)} -
- -
+ {/* Decorative Rings */} +
+
-
-
- {/* Visual Eyecatcher: Bottle Preview */} - {tastings.length > 0 && tastings[0].bottles.image_url && ( -
-
- {tastings[0].bottles.name} -
-
-
- LATEST -
+
+
+
+
+ + Tasting Session
- )} - -
-
-
- - Tasting Session -
- {session.ended_at && ( - Abgeschlossen - )} -
-

- {session.name} -

-
- - - {new Date(session.scheduled_at).toLocaleDateString('de-DE')} + {session.ended_at && ( + Archiviert + )} + {session.is_blind && ( + +
+ Blind Modus - {participants.length > 0 && ( -
- - p.buddies.name)} limit={5} /> -
- )} - {tastings.length > 0 && ( - - - {tastings.length} {tastings.length === 1 ? 'Whisky' : 'Whiskys'} - - )} + )} + {session.is_blind && session.is_revealed && ( + + + Revealed + + )} +
+ +

+ {session.name} +

+ +
+
+ + {new Date(session.scheduled_at).toLocaleDateString('de-DE')} +
+ {participants.length > 0 && ( +
+ + p.buddies.name)} limit={5} /> +
+ )} +
+ + {tastings.length} {tastings.length === 1 ? 'DRAM' : 'DRAMS'}
-
+
+ {/* Host Controls for Blind Mode */} + {user?.id === session.user_id && !session.ended_at && ( + <> + + + {session.is_blind && !session.is_revealed && ( + + )} + + )} + {!session.ended_at && ( activeSession?.id !== session.id ? ( ) )} -
-
- {/* Sidebar: Participants */} - - {/* Main Content: Bottle List */} -
-
-
-

- - Verkostete Flaschen -

-
+ {/* Team */} +
+

+ + + Crew + + {participants.length} +

+ +
+ {participants.length === 0 ? ( +

Noch keiner an Bord...

+ ) : ( + participants.map((p) => ( +
+
+
+ {p.buddies.name[0]} +
+ {p.buddies.name} +
+ +
+ )) + )} +
+ +
+

Buddy hinzufügen

+ +
+
+
+ + {/* Main Feed: Timeline */} +
+
+
+
+

Timeline

+

Verkostungs-Historie

+
+
{!session.ended_at && ( )} - fileInputRef.current?.click()} + className="flex-1 md:flex-none bg-orange-600 hover:bg-orange-500 text-white px-6 py-3 rounded-2xl text-[10px] font-black uppercase tracking-widest flex items-center justify-center gap-2 transition-all shadow-xl shadow-orange-950/20" > Flasche - +
@@ -447,24 +623,38 @@ export default function SessionDetailPage() { tags: t.tasting_tags?.map((tg: any) => tg.tags.name) || [], category: t.bottles.category }))} - sessionStart={session.scheduled_at} // Fallback to scheduled time if no started_at + sessionStart={session.scheduled_at} + isBlind={session.is_blind} + isRevealed={session.is_revealed} /> -
-
+ +
- {/* Bulk Scan Sheet */} setIsBulkScanOpen(false)} sessionId={id as string} sessionName={session.name} - onSuccess={(bottleIds) => { + onSuccess={() => { setIsBulkScanOpen(false); fetchSessionData(); }} /> + + { + setIsScanFlowOpen(false); + setSelectedFile(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + }} + imageFile={selectedFile} + onBottleSaved={() => { + fetchSessionData(); + }} + />
); } diff --git a/src/components/ActiveSessionBanner.tsx b/src/components/ActiveSessionBanner.tsx index 1cc8dce..4663fab 100644 --- a/src/components/ActiveSessionBanner.tsx +++ b/src/components/ActiveSessionBanner.tsx @@ -6,43 +6,62 @@ import { GlassWater, Square, ArrowRight, Sparkles } from 'lucide-react'; import Link from 'next/link'; import { useI18n } from '@/i18n/I18nContext'; +import { motion, AnimatePresence } from 'framer-motion'; + export default function ActiveSessionBanner() { const { activeSession, setActiveSession } = useSession(); const { t } = useI18n(); - if (!activeSession) return null; - return ( -
-
- + {activeSession && ( + -
-
- -
-
-
-
-
- Live Jetzt -

{t('session.activeSession')}

-
-

{activeSession.name}

-
- - +
+ {/* Session Info Link */} + +
+
+ +
+
+
+
+
+ Live +

{t('session.activeSession')}

+
+

{activeSession.name}

+
+ - -
-
+ {/* Action Buttons */} +
+ + + +
+ +
+
+ + )} + ); } diff --git a/src/components/BackgroundRemovalHandler.tsx b/src/components/BackgroundRemovalHandler.tsx new file mode 100644 index 0000000..fb4eabe --- /dev/null +++ b/src/components/BackgroundRemovalHandler.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { useImageProcessor } from '@/hooks/useImageProcessor'; +import { db } from '@/lib/db'; +import { FEATURES } from '@/config/features'; + +/** + * Global handler for background AI image processing. + * Mount this in root layout to ensure processing continues in background. + * It also scans for unprocessed local images on load. + */ +export default function BackgroundRemovalHandler() { + const { addToQueue } = useImageProcessor(); + const hasScannedRef = useRef(false); + + useEffect(() => { + if (!FEATURES.ENABLE_AI_BG_REMOVAL) return; + if (hasScannedRef.current) return; + hasScannedRef.current = true; + + const scanAndQueue = async () => { + try { + // 1. Check pending_scans (offline scans) + const pendingScans = await db.pending_scans + .filter(scan => !scan.bgRemoved) + .toArray(); + + for (const scan of pendingScans) { + if (scan.imageBase64 && scan.temp_id) { + // Convert base64 back to blob for the worker + const res = await fetch(scan.imageBase64); + const blob = await res.blob(); + addToQueue(scan.temp_id, blob); + } + } + + // 2. Check cache_bottles (successfully saved bottles) + const cachedBottles = await db.cache_bottles + .filter(bottle => !bottle.bgRemoved) + .limit(10) // Limit to avoid overwhelming on start + .toArray(); + + for (const bottle of cachedBottles) { + if (bottle.image_url && bottle.id) { + try { + const res = await fetch(bottle.image_url); + const blob = await res.blob(); + addToQueue(bottle.id, blob); + } catch (e) { + console.warn(`[BG-Removal] Failed to fetch image for bottle ${bottle.id}:`, e); + } + } + } + } catch (err) { + console.error('[BG-Removal] Initial scan error:', err); + } + }; + + // Delay slightly to not block initial app boot + const timer = setTimeout(scanAndQueue, 3000); + return () => clearTimeout(timer); + }, [addToQueue]); + + return null; // Logic-only component +} diff --git a/src/components/BottleDetails.tsx b/src/components/BottleDetails.tsx index 0d45163..735b422 100644 --- a/src/components/BottleDetails.tsx +++ b/src/components/BottleDetails.tsx @@ -2,7 +2,7 @@ import React from 'react'; import Link from 'next/link'; -import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, Info, Loader2, WifiOff, CircleDollarSign, Wine, CheckCircle2, Circle, ChevronDown, Plus, Share2 } from 'lucide-react'; +import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, Info, Loader2, WifiOff, CircleDollarSign, Wine, CheckCircle2, Circle, ChevronDown, Plus, Share2, TrendingUp } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { updateBottle } from '@/services/update-bottle'; import { getStorageUrl } from '@/lib/supabase'; @@ -12,6 +12,8 @@ import DeleteBottleButton from '@/components/DeleteBottleButton'; import EditBottleForm from '@/components/EditBottleForm'; import { useBottleData } from '@/hooks/useBottleData'; import { useI18n } from '@/i18n/I18nContext'; +import FlavorRadar from './FlavorRadar'; + interface BottleDetailsProps { bottleId: string; @@ -167,6 +169,47 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet exit={{ opacity: 0, x: 20 }} className="p-6 md:p-8 space-y-8" > + {/* Flavor Profile Section */} + {tastings && tastings.some((t: any) => t.flavor_profile) && ( +
+
+ + Average Flavor Profile +
+
+
+ { + const validProfiles = tastings.filter((t: any) => t.flavor_profile).map((t: any) => t.flavor_profile); + const count = validProfiles.length; + return { + smoky: Math.round(validProfiles.reduce((s, p) => s + p.smoky, 0) / count), + fruity: Math.round(validProfiles.reduce((s, p) => s + p.fruity, 0) / count), + spicy: Math.round(validProfiles.reduce((s, p) => s + p.spicy, 0) / count), + sweet: Math.round(validProfiles.reduce((s, p) => s + p.sweet, 0) / count), + floral: Math.round(validProfiles.reduce((s, p) => s + p.floral, 0) / count), + }; + })()} + size={220} + /> +
+
+

+ Basierend auf {tastings.filter((t: any) => t.flavor_profile).length} Verkostungen. Dieses Diagramm zeigt den durchschnittlichen Charakter dieser Flasche. +

+
+ {['smoky', 'fruity', 'spicy', 'sweet', 'floral'].map(attr => ( +
+
+ {attr} +
+ ))} +
+
+
+
+ )} + {/* Fact Grid - Integrated Metadata & Stats */}
} /> diff --git a/src/components/BottleGrid.tsx b/src/components/BottleGrid.tsx index 77737b8..dd82cad 100644 --- a/src/components/BottleGrid.tsx +++ b/src/components/BottleGrid.tsx @@ -32,77 +32,91 @@ interface BottleCardProps { function BottleCard({ bottle, sessionId }: BottleCardProps) { const { t, locale } = useI18n(); + const imageUrl = getStorageUrl(bottle.image_url); return ( - {/* Image Layer - Clean Split Top */} -
- {bottle.name} -
+ {/* === SPOTIFY-STYLE IMAGE SECTION === */} +
- {/* Info Layer - Clean Split Bottom */} -
-
-

- {bottle.distillery} -

-

- {bottle.name || t('grid.unknownBottle')} -

+ {/* Layer 1: Blurred Backdrop */} +
+ + {/* Vignette Overlay */} +
-
-
- + {/* Layer 2: Sharp Foreground Image */} +
+ {bottle.name} +
+ + {/* Top Overlays */} + {(bottle.is_whisky === false || (bottle.confidence && bottle.confidence < 70)) && ( +
+
+ +
+
+ )} + + {sessionId && ( +
+ + ADD +
+ )} + + {/* Bottom Gradient Overlay for Text */} +
+ + {/* Info Overlay at Bottom */} +
+

+ {bottle.distillery} +

+

+ {bottle.name || t('grid.unknownBottle')} +

+ +
+ {shortenCategory(bottle.category)} - + {bottle.abv}% VOL
- - {/* Metadata items */} -
-
- - {new Date(bottle.created_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')} -
- {bottle.last_tasted && ( -
- - {new Date(bottle.last_tasted).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')} -
- )} -
- - {/* Top Overlays */} - {(bottle.is_whisky === false || (bottle.confidence && bottle.confidence < 70)) && ( -
-
- -
-
- )} - - {sessionId && ( -
- - ADD -
- )} ); } + interface BottleGridProps { bottles: any[]; } diff --git a/src/components/CameraCapture.tsx b/src/components/CameraCapture.tsx index 93b2d15..8ed1e5d 100644 --- a/src/components/CameraCapture.tsx +++ b/src/components/CameraCapture.tsx @@ -19,6 +19,8 @@ import { shortenCategory } from '@/lib/format'; import { scanLabel } from '@/app/actions/scanner'; import { enrichData } from '@/app/actions/enrich-data'; import { processImageForAI } from '@/utils/image-processing'; +import { runCascadeOCR } from '@/services/cascade-ocr'; +import { FEATURES } from '@/config/features'; interface CameraCaptureProps { onImageCaptured?: (base64Image: string) => void; @@ -64,7 +66,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS const [isDiscovering, setIsDiscovering] = useState(false); const [originalFile, setOriginalFile] = useState(null); const [isAdmin, setIsAdmin] = useState(false); - const [aiProvider, setAiProvider] = useState<'gemini' | 'mistral'>('gemini'); + const [aiProvider, setAiProvider] = useState<'gemini' | 'openrouter'>('gemini'); const [perfMetrics, setPerfMetrics] = useState<{ compression: number; @@ -159,6 +161,13 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS const formData = new FormData(); formData.append('file', processed.file); + // Run Cascade OCR in parallel (for comparison/logging only - doesn't block AI) + if (FEATURES.ENABLE_CASCADE_OCR) { + runCascadeOCR(processed.file).catch(err => { + console.warn('[CameraCapture] Cascade OCR failed:', err); + }); + } + const startAi = performance.now(); const response = await scanLabel(formData); const endAi = performance.now(); @@ -298,10 +307,10 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS Gemini
)} diff --git a/src/components/EditBottleForm.tsx b/src/components/EditBottleForm.tsx index d8f4be3..9115e17 100644 --- a/src/components/EditBottleForm.tsx +++ b/src/components/EditBottleForm.tsx @@ -36,10 +36,10 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro name: bottle.name, distillery: bottle.distillery || '', category: bottle.category || '', - abv: bottle.abv || 0, - age: bottle.age || 0, + abv: bottle.abv?.toString() || '', + age: bottle.age?.toString() || '', whiskybase_id: bottle.whiskybase_id || '', - purchase_price: bottle.purchase_price || '', + purchase_price: bottle.purchase_price?.toString() || '', distilled_at: bottle.distilled_at || '', bottled_at: bottle.bottled_at || '', batch_info: bottle.batch_info || '', @@ -54,8 +54,8 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro const result = await discoverWhiskybaseId({ name: formData.name, distillery: formData.distillery, - abv: formData.abv, - age: formData.age, + abv: formData.abv ? parseFloat(formData.abv) : undefined, + age: formData.age ? parseInt(formData.age) : undefined, distilled_at: formData.distilled_at || undefined, bottled_at: formData.bottled_at || undefined, batch_info: formData.batch_info || undefined, @@ -83,14 +83,14 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro try { const response = await updateBottle(bottle.id, { ...formData, - abv: Number(formData.abv), - age: formData.age ? Number(formData.age) : undefined, - purchase_price: formData.purchase_price ? Number(formData.purchase_price) : undefined, + abv: formData.abv ? parseFloat(formData.abv.replace(',', '.')) : null, + age: formData.age ? parseInt(formData.age) : null, + purchase_price: formData.purchase_price ? parseFloat(formData.purchase_price.replace(',', '.')) : null, distilled_at: formData.distilled_at || undefined, bottled_at: formData.bottled_at || undefined, batch_info: formData.batch_info || undefined, cask_type: formData.cask_type || undefined, - }); + } as any); if (response.success) { setIsEditing(false); @@ -145,22 +145,23 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
setFormData({ ...formData, abv: parseFloat(e.target.value) })} + onChange={(e) => setFormData({ ...formData, abv: e.target.value })} className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-orange-500 text-sm font-bold transition-all" + placeholder="e.g. 46.3" />
setFormData({ ...formData, age: parseInt(e.target.value) })} + onChange={(e) => setFormData({ ...formData, age: e.target.value })} className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all" + placeholder="e.g. 12" />
@@ -196,9 +197,8 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
setFormData({ ...formData, purchase_price: e.target.value })} diff --git a/src/components/FlavorRadar.tsx b/src/components/FlavorRadar.tsx new file mode 100644 index 0000000..4d93039 --- /dev/null +++ b/src/components/FlavorRadar.tsx @@ -0,0 +1,65 @@ +'use client'; + +import React from 'react'; +import { + Radar, + RadarChart, + PolarGrid, + PolarAngleAxis, + PolarRadiusAxis, + ResponsiveContainer +} from 'recharts'; + +interface FlavorProfile { + smoky: number; + fruity: number; + spicy: number; + sweet: number; + floral: number; +} + +interface FlavorRadarProps { + profile: FlavorProfile; + size?: number; + showAxis?: boolean; +} + +export default function FlavorRadar({ profile, size = 300, showAxis = true }: FlavorRadarProps) { + const data = [ + { subject: 'Smoky', A: profile.smoky, fullMark: 100 }, + { subject: 'Fruity', A: profile.fruity, fullMark: 100 }, + { subject: 'Spicy', A: profile.spicy, fullMark: 100 }, + { subject: 'Sweet', A: profile.sweet, fullMark: 100 }, + { subject: 'Floral', A: profile.floral, fullMark: 100 }, + ]; + + return ( +
+ + + + + {!showAxis && } + {showAxis && ( + + )} + + + +
+ ); +} diff --git a/src/components/NativeOCRScanner.tsx b/src/components/NativeOCRScanner.tsx new file mode 100644 index 0000000..e086992 --- /dev/null +++ b/src/components/NativeOCRScanner.tsx @@ -0,0 +1,277 @@ +'use client'; + +/** + * Native OCR Scanner Component + * + * Uses the Shape Detection API (TextDetector) for zero-latency, + * zero-download OCR directly from the camera stream. + * + * Only works on Android/Chrome/Edge. iOS uses the Live Text fallback. + */ + +import React, { useRef, useEffect, useState, useCallback } from 'react'; +import { X, Camera, Loader2, Zap, CheckCircle } from 'lucide-react'; +import { useScanFlow } from '@/hooks/useScanFlow'; +import { normalizeDistillery } from '@/lib/distillery-matcher'; + +interface NativeOCRScannerProps { + isOpen: boolean; + onClose: () => void; + onTextDetected: (texts: string[]) => void; + onAutoCapture?: (result: { + rawTexts: string[]; + distillery: string | null; + abv: number | null; + age: number | null; + }) => void; +} + +// RegEx patterns for auto-extraction +const PATTERNS = { + abv: /(\d{1,2}[.,]\d{1}|\d{1,2})\s*%\s*(?:vol|alc)?/i, + age: /(\d{1,2})\s*(?:years?|yo|y\.?o\.?|jahre?)\s*(?:old)?/i, +}; + +export default function NativeOCRScanner({ + isOpen, + onClose, + onTextDetected, + onAutoCapture +}: NativeOCRScannerProps) { + const videoRef = useRef(null); + const streamRef = useRef(null); + const animationRef = useRef(null); + + const { processVideoFrame } = useScanFlow(); + + const [isStreaming, setIsStreaming] = useState(false); + const [detectedTexts, setDetectedTexts] = useState([]); + const [extractedData, setExtractedData] = useState<{ + distillery: string | null; + abv: number | null; + age: number | null; + }>({ distillery: null, abv: null, age: null }); + const [isAutoCapturing, setIsAutoCapturing] = useState(false); + + // Start camera stream + const startStream = useCallback(async () => { + try { + console.log('[NativeOCR] Starting camera stream...'); + const stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: 'environment', + width: { ideal: 1280 }, + height: { ideal: 720 }, + }, + }); + + streamRef.current = stream; + + if (videoRef.current) { + videoRef.current.srcObject = stream; + await videoRef.current.play(); + setIsStreaming(true); + console.log('[NativeOCR] Camera stream started'); + } + } catch (err) { + console.error('[NativeOCR] Camera access failed:', err); + } + }, []); + + // Stop camera stream + const stopStream = useCallback(() => { + console.log('[NativeOCR] Stopping camera stream...'); + + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + animationRef.current = null; + } + + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + streamRef.current = null; + } + + if (videoRef.current) { + videoRef.current.srcObject = null; + } + + setIsStreaming(false); + setDetectedTexts([]); + }, []); + + // Process frames continuously + const processLoop = useCallback(async () => { + if (!videoRef.current || !isStreaming) return; + + const texts = await processVideoFrame(videoRef.current); + + if (texts.length > 0) { + setDetectedTexts(texts); + onTextDetected(texts); + + // Try to extract structured data + const allText = texts.join(' '); + + // ABV + const abvMatch = allText.match(PATTERNS.abv); + const abv = abvMatch ? parseFloat(abvMatch[1].replace(',', '.')) : null; + + // Age + const ageMatch = allText.match(PATTERNS.age); + const age = ageMatch ? parseInt(ageMatch[1], 10) : null; + + // Distillery (fuzzy match) + let distillery: string | null = null; + for (const text of texts) { + if (text.length >= 4 && text.length <= 40) { + const match = normalizeDistillery(text); + if (match.matched) { + distillery = match.name; + break; + } + } + } + + setExtractedData({ distillery, abv, age }); + + // Auto-capture if we have enough data + if (distillery && (abv || age) && !isAutoCapturing) { + console.log('[NativeOCR] Auto-capture triggered:', { distillery, abv, age }); + setIsAutoCapturing(true); + + if (onAutoCapture) { + onAutoCapture({ + rawTexts: texts, + distillery, + abv, + age, + }); + } + + // Visual feedback before closing + setTimeout(() => { + onClose(); + }, 1500); + } + } + + // Continue loop (throttled to ~5 FPS for performance) + animationRef.current = window.setTimeout(() => { + requestAnimationFrame(processLoop); + }, 200) as unknown as number; + }, [isStreaming, processVideoFrame, onTextDetected, onAutoCapture, isAutoCapturing, onClose]); + + // Start/stop based on isOpen + useEffect(() => { + if (isOpen) { + startStream(); + } else { + stopStream(); + } + + return () => { + stopStream(); + }; + }, [isOpen, startStream, stopStream]); + + // Start processing loop when streaming + useEffect(() => { + if (isStreaming) { + processLoop(); + } + }, [isStreaming, processLoop]); + + if (!isOpen) return null; + + return ( +
+ {/* Header */} +
+
+ + Native OCR +
+ +
+ + {/* Video Feed */} +