Compare commits
2 commits
af8647f3aa
...
2218c9a84c
| Author | SHA1 | Date | |
|---|---|---|---|
| 2218c9a84c | |||
| c67bb02173 |
44 changed files with 2751 additions and 358 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -67,3 +67,4 @@ www/admin/_V4/**
|
||||||
www/csv/**
|
www/csv/**
|
||||||
www/admin/_sounds/**
|
www/admin/_sounds/**
|
||||||
www/mp3/**
|
www/mp3/**
|
||||||
|
faceai/logs/**
|
||||||
|
|
@ -61,6 +61,7 @@ Use three deployable parts:
|
||||||
|
|
||||||
- Read the handoff token or FaceAI session cookie.
|
- Read the handoff token or FaceAI session cookie.
|
||||||
- Show the legacy-like header and navigation.
|
- Show the legacy-like header and navigation.
|
||||||
|
- Check whether the mounted FaceAI dataset exists for the selected race before enabling uploads.
|
||||||
- Let the user upload a selfie.
|
- Let the user upload a selfie.
|
||||||
- Create a race-scoped search request.
|
- Create a race-scoped search request.
|
||||||
- Poll job status or show queued state.
|
- Poll job status or show queued state.
|
||||||
|
|
@ -72,7 +73,7 @@ Use three deployable parts:
|
||||||
|
|
||||||
- Receive a race-scoped search job.
|
- Receive a race-scoped search job.
|
||||||
- Queue requests and process them one by one.
|
- Queue requests and process them one by one.
|
||||||
- Run the external face-recognition program.
|
- Resolve `year/monthFolder/raceFolder` inside the mounted dataset root, take the first `.pkl` file in that race directory, and run the external face-recognition program against it.
|
||||||
- Return match results with confidence and photo ids or file identifiers.
|
- Return match results with confidence and photo ids or file identifiers.
|
||||||
- Return a completed result set usable by the legacy filter handoff.
|
- Return a completed result set usable by the legacy filter handoff.
|
||||||
|
|
||||||
|
|
@ -95,6 +96,10 @@ Instead:
|
||||||
- access flags for FaceAI
|
- access flags for FaceAI
|
||||||
- race id
|
- race id
|
||||||
- race slug or descriptor
|
- race slug or descriptor
|
||||||
|
- race storage metadata needed to resolve the mounted FaceAI dataset:
|
||||||
|
- `year`
|
||||||
|
- `monthFolder` like `04.APRILE`
|
||||||
|
- `raceFolder` like `LIVORNO` or `PISA`
|
||||||
- current page URL as `returnUrl`
|
- current page URL as `returnUrl`
|
||||||
- expiry time, ideally 1 to 5 minutes
|
- expiry time, ideally 1 to 5 minutes
|
||||||
3. Browser is redirected to `https://faceai.regalamiunsorriso.it/auth/callback?token=...`
|
3. Browser is redirected to `https://faceai.regalamiunsorriso.it/auth/callback?token=...`
|
||||||
|
|
@ -138,7 +143,7 @@ The lowest-risk way to do that is to update `www/_js/rus-ecom-240621.js` so that
|
||||||
- removes that select from the rendered UI
|
- removes that select from the rendered UI
|
||||||
- inserts a `Face ID` button in the same area
|
- inserts a `Face ID` button in the same area
|
||||||
- builds the launch URL using the current race context and current page URL
|
- builds the launch URL using the current race context and current page URL
|
||||||
- carries `raceId`, race description or slug, language, and exact `returnUrl`
|
- carries `raceId`, race description or slug, `raceYear`, `raceMonthFolder`, `raceFolder`, language, and exact `returnUrl`
|
||||||
|
|
||||||
This avoids fragile JSP layout edits and keeps the change deployable as a single JS asset update.
|
This avoids fragile JSP layout edits and keeps the change deployable as a single JS asset update.
|
||||||
|
|
||||||
|
|
@ -172,7 +177,7 @@ This is preferable to putting the matched ids directly in the browser URL, becau
|
||||||
|
|
||||||
## FaceAI App Structure
|
## FaceAI App Structure
|
||||||
|
|
||||||
The requested target folder is `faceai/`. It does not currently exist in this workspace, so this plan assumes it will be created as a new app.
|
The target folder is `faceai/`, and this workspace now contains an implemented scaffold there.
|
||||||
|
|
||||||
Suggested structure:
|
Suggested structure:
|
||||||
|
|
||||||
|
|
@ -198,10 +203,11 @@ faceai/
|
||||||
5. FaceAI shows a page styled like the old site, including a matching header and a clear `Back to race page` action.
|
5. FaceAI shows a page styled like the old site, including a matching header and a clear `Back to race page` action.
|
||||||
6. User uploads a selfie.
|
6. User uploads a selfie.
|
||||||
7. FaceAI creates a search job with `userId`, `raceId`, `requestId`, and selfie file reference.
|
7. FaceAI creates a search job with `userId`, `raceId`, `requestId`, and selfie file reference.
|
||||||
8. FaceAI polls until the processing job completes.
|
8. FaceAI checks the mounted race directory immediately and, if no `.pkl` is present for that race, disables processing and offers only the return path.
|
||||||
9. Once the result is ready, FaceAI redirects the browser back to the original race page on `www`.
|
9. FaceAI polls until the processing job completes.
|
||||||
10. The legacy site resolves the matched photo ids and renders the race page filtered to those photos only, similar in spirit to the existing pettorale-based flow.
|
10. Once the result is ready, FaceAI redirects the browser back to the original race page on `www`.
|
||||||
11. User opens and downloads photos exactly as they do today, through the legacy site.
|
11. The legacy site resolves the matched photo ids and renders the race page filtered to those photos only, similar in spirit to the existing pettorale-based flow.
|
||||||
|
12. User opens and downloads photos exactly as they do today, through the legacy site.
|
||||||
|
|
||||||
## Result And Download Strategy
|
## Result And Download Strategy
|
||||||
|
|
||||||
|
|
@ -255,6 +261,20 @@ For v1, `photoId` is the most important field. If the legacy page is the final r
|
||||||
|
|
||||||
Race scope is mandatory. The service must never search globally by default.
|
Race scope is mandatory. The service must never search globally by default.
|
||||||
|
|
||||||
|
The mounted dataset layout is now assumed to be:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/mounted-pkl-root/
|
||||||
|
2026/
|
||||||
|
04.APRILE/
|
||||||
|
PISA/
|
||||||
|
any-file-name.pkl
|
||||||
|
LIVORNO/
|
||||||
|
any-file-name.pkl
|
||||||
|
```
|
||||||
|
|
||||||
|
The `.pkl` filename does not matter. The first `.pkl` found at the race root is the one passed to the matcher.
|
||||||
|
|
||||||
## Async Processing Design
|
## Async Processing Design
|
||||||
|
|
||||||
Use an API plus worker model.
|
Use an API plus worker model.
|
||||||
|
|
@ -275,6 +295,7 @@ Input job:
|
||||||
|
|
||||||
- request id
|
- request id
|
||||||
- race id
|
- race id
|
||||||
|
- race storage metadata: `year`, `monthFolder`, `raceFolder`
|
||||||
- selfie storage path
|
- selfie storage path
|
||||||
- user id
|
- user id
|
||||||
- email
|
- email
|
||||||
|
|
@ -370,7 +391,7 @@ This is safer than trying to embed the old JSP header directly into a Node app.
|
||||||
|
|
||||||
- Update `www/_js/rus-ecom-240621.js` to remove the dropdown from the UI and insert the FaceAI button.
|
- Update `www/_js/rus-ecom-240621.js` to remove the dropdown from the UI and insert the FaceAI button.
|
||||||
- Add the legacy auth bridge endpoint.
|
- Add the legacy auth bridge endpoint.
|
||||||
- Pass `raceId`, `lang`, and `returnUrl` into the FaceAI launch.
|
- Pass `raceId`, `lang`, `returnUrl`, `raceYear`, `raceMonthFolder`, and `raceFolder` into the FaceAI launch.
|
||||||
- Add the legacy return endpoint or result-aware race filter path.
|
- Add the legacy return endpoint or result-aware race filter path.
|
||||||
|
|
||||||
### Phase 3: FaceAI app shell
|
### Phase 3: FaceAI app shell
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,10 @@ FACEAI_ENABLE_LOCAL_LEGACY_STATIC=1
|
||||||
FACEAI_LOCAL_LEGACY_STATIC_ROOT=k:\various\regalamiunsorriso\www
|
FACEAI_LOCAL_LEGACY_STATIC_ROOT=k:\various\regalamiunsorriso\www
|
||||||
FACEAI_SHARED_SECRET=change-me
|
FACEAI_SHARED_SECRET=change-me
|
||||||
FACEAI_SESSION_COOKIE=rus_faceai_session
|
FACEAI_SESSION_COOKIE=rus_faceai_session
|
||||||
|
FACEAI_REDIS_URL=redis://redis:6379
|
||||||
|
FACEAI_QUEUE_NAME=faceai-searches
|
||||||
|
FACEAI_RUNTIME_ROOT=/data/runtime
|
||||||
|
FACEAI_UPLOAD_ROOT=/data/runtime/uploads
|
||||||
|
FACEAI_LOG_ROOT=/data/logs
|
||||||
|
FACEAI_PKL_ROOT=/data/pkl
|
||||||
|
FACEAI_MATCHER_BINARY=/opt/face-recognition/face_matcher
|
||||||
|
|
|
||||||
2
faceai/.gitignore
vendored
2
faceai/.gitignore
vendored
|
|
@ -1,3 +1,5 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
apps/frontend/dist/
|
apps/frontend/dist/
|
||||||
.env
|
.env
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
|
|
||||||
|
|
@ -76,15 +76,33 @@ The checked-in `docker-compose.yml` starts:
|
||||||
The local stack also mounts:
|
The local stack also mounts:
|
||||||
|
|
||||||
- `../bin/Face_Recognition_Unix` into the processor container as the matcher binary source
|
- `../bin/Face_Recognition_Unix` into the processor container as the matcher binary source
|
||||||
- `../test_pkl` into the processor container as fallback PKL test data
|
- `../test_pkl` into both the public FaceAI container and the processor container as the shared read-only PKL dataset root
|
||||||
|
- `./logs` into both the public FaceAI container and the processor container as the persistent diagnostics directory
|
||||||
- `../www` into the PHP container so the real bridge files are used
|
- `../www` into the PHP container so the real bridge files are used
|
||||||
|
|
||||||
|
The `processor` service is built from `docker/processor.Dockerfile`, which uses a Debian Trixie-based Node 22 image, applies the current package upgrades available during build, and installs `libxcb1` so the bundled Linux `face_matcher` binary can run locally.
|
||||||
|
|
||||||
|
### Persistent Logs
|
||||||
|
|
||||||
|
The checked-in local Compose stack now redirects the relevant Node service logs into `faceai/logs` on the host.
|
||||||
|
|
||||||
|
After `docker compose up --build`, inspect:
|
||||||
|
|
||||||
|
- `faceai/logs/backend.log` for backend startup and API-side failures
|
||||||
|
- `faceai/logs/processor.log` for worker startup, queue processing, and uncaught processor errors
|
||||||
|
- `faceai/logs/searches/<searchId>/worker.log` for the per-search processor trace
|
||||||
|
- `faceai/logs/searches/<searchId>/matcher.log` for the native `face_matcher` output
|
||||||
|
|
||||||
|
This keeps the useful processor diagnostics outside the Docker-managed runtime volume so they survive container rebuilds and can be inspected directly from the workspace.
|
||||||
|
|
||||||
|
The current bundled Linux `face_matcher` binary is a PyInstaller build that requires `GLIBC_2.38` or newer and the `libxcb.so.1` runtime library. The checked-in local processor image satisfies that requirement.
|
||||||
|
|
||||||
### Run The Browser Test
|
### Run The Browser Test
|
||||||
|
|
||||||
Open:
|
Open:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://localhost:8080/faceai_simulator.php?raceId=101&lang=it
|
http://localhost:8080/faceai_simulator.php?raceId=202&lang=it
|
||||||
```
|
```
|
||||||
|
|
||||||
That page simulates the legacy race page, loads the original race-page JavaScript from `www/_js/rus-ecom-240621.js`, lets the script replace the visible `tipoPuntoFoto` selector with the new `Face ID` button, and launches the real PHP handoff bridge at `www/faceai_handoff.php`.
|
That page simulates the legacy race page, loads the original race-page JavaScript from `www/_js/rus-ecom-240621.js`, lets the script replace the visible `tipoPuntoFoto` selector with the new `Face ID` button, and launches the real PHP handoff bridge at `www/faceai_handoff.php`.
|
||||||
|
|
@ -115,6 +133,43 @@ If you want to stop and remove the local containers afterward, run:
|
||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Automated End-To-End Test
|
||||||
|
|
||||||
|
The workspace now includes a Playwright suite that drives the PHP simulator, the FaceAI app, and the processor end to end.
|
||||||
|
|
||||||
|
From this folder, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run test:e2e:install
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
The suite will:
|
||||||
|
|
||||||
|
- build the frontend bundle
|
||||||
|
- start `docker compose up --build -d`
|
||||||
|
- open `http://localhost:8080/faceai_simulator.php?raceId=202&lang=it`
|
||||||
|
- click the `Face ID` launch button injected by `www/_js/rus-ecom-240621.js`
|
||||||
|
- upload `test_pkl/test_images/DSC_1960.JPG`
|
||||||
|
- wait for the processor to complete and for FaceAI to redirect to `faceai_return.php`
|
||||||
|
- assert the filtered legacy result contains the expected `6` matches and includes `DSC_1960.JPG`
|
||||||
|
- validate `faceai/logs/backend.log`, `faceai/logs/processor.log`, and the per-search `worker.log` and `matcher.log` for the run
|
||||||
|
- stop the Compose stack automatically when the suite finishes
|
||||||
|
|
||||||
|
The default deterministic fixture can be overridden with environment variables if the dataset changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FACEAI_E2E_SELFIE=DSC_1960.JPG
|
||||||
|
FACEAI_E2E_EXPECTED_MATCH_COUNT=6
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to keep the local containers running after the test for manual inspection, set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FACEAI_E2E_KEEP_STACK=1
|
||||||
|
```
|
||||||
|
|
||||||
## Optional Backend And Frontend Dev Loop
|
## Optional Backend And Frontend Dev Loop
|
||||||
|
|
||||||
If you only want to iterate on the app without the PHP simulator, you can still run the public site and the processor separately. The queue-backed flow now requires Redis and the processor, so `npm run dev` alone is no longer the full stack.
|
If you only want to iterate on the app without the PHP simulator, you can still run the public site and the processor separately. The queue-backed flow now requires Redis and the processor, so `npm run dev` alone is no longer the full stack.
|
||||||
|
|
@ -138,6 +193,8 @@ The public FaceAI site and the matcher runner can both use the same application
|
||||||
- `npm run start` for the public site
|
- `npm run start` for the public site
|
||||||
- `npm run start:processor` for the matcher runner
|
- `npm run start:processor` for the matcher runner
|
||||||
|
|
||||||
|
If that shared image also embeds or mounts the current Linux `face_matcher` build, make sure the base OS provides `GLIBC_2.38` or newer and includes `libxcb1`. A Debian Trixie-based image with that package installed satisfies the requirement; a Bookworm-based image does not.
|
||||||
|
|
||||||
### Production Compose Example
|
### Production Compose Example
|
||||||
|
|
||||||
Replace the registry path, secrets, and host paths with the real deployment values.
|
Replace the registry path, secrets, and host paths with the real deployment values.
|
||||||
|
|
@ -148,6 +205,7 @@ services:
|
||||||
image: registry.example.com/my-namespace/faceai:latest
|
image: registry.example.com/my-namespace/faceai:latest
|
||||||
container_name: regalami-faceai
|
container_name: regalami-faceai
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
command: sh -c "mkdir -p /data/logs && npm run start >> /data/logs/backend.log 2>&1"
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
|
|
@ -160,9 +218,13 @@ services:
|
||||||
FACEAI_QUEUE_NAME: faceai-searches
|
FACEAI_QUEUE_NAME: faceai-searches
|
||||||
FACEAI_RUNTIME_ROOT: /data/runtime
|
FACEAI_RUNTIME_ROOT: /data/runtime
|
||||||
FACEAI_UPLOAD_ROOT: /data/runtime/uploads
|
FACEAI_UPLOAD_ROOT: /data/runtime/uploads
|
||||||
|
FACEAI_LOG_ROOT: /data/logs
|
||||||
|
FACEAI_PKL_ROOT: /data/pkl
|
||||||
FACEAI_ENABLE_LOCAL_LEGACY_STATIC: 0
|
FACEAI_ENABLE_LOCAL_LEGACY_STATIC: 0
|
||||||
volumes:
|
volumes:
|
||||||
- faceai-runtime:/data/runtime
|
- faceai-runtime:/data/runtime
|
||||||
|
- /srv/faceai/logs:/data/logs
|
||||||
|
- /srv/faceai/pkl:/data/pkl:ro
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3001:3001"
|
- "127.0.0.1:3001:3001"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
@ -172,18 +234,20 @@ services:
|
||||||
image: registry.example.com/my-namespace/faceai:latest
|
image: registry.example.com/my-namespace/faceai:latest
|
||||||
container_name: regalami-faceai-processor
|
container_name: regalami-faceai-processor
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: npm run start:processor
|
command: sh -c "mkdir -p /data/logs && npm run start:processor >> /data/logs/processor.log 2>&1"
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
FACEAI_REDIS_URL: redis://redis:6379
|
FACEAI_REDIS_URL: redis://redis:6379
|
||||||
FACEAI_QUEUE_NAME: faceai-searches
|
FACEAI_QUEUE_NAME: faceai-searches
|
||||||
FACEAI_RUNTIME_ROOT: /data/runtime
|
FACEAI_RUNTIME_ROOT: /data/runtime
|
||||||
|
FACEAI_LOG_ROOT: /data/logs
|
||||||
FACEAI_PKL_ROOT: /data/pkl
|
FACEAI_PKL_ROOT: /data/pkl
|
||||||
FACEAI_MATCHER_BINARY: /opt/face-recognition/face_matcher
|
FACEAI_MATCHER_BINARY: /opt/face-recognition/face_matcher
|
||||||
FACEAI_WORKER_CONCURRENCY: 2
|
FACEAI_WORKER_CONCURRENCY: 2
|
||||||
FACEAI_WORKER_TIMEOUT_MS: 300000
|
FACEAI_WORKER_TIMEOUT_MS: 300000
|
||||||
volumes:
|
volumes:
|
||||||
- faceai-runtime:/data/runtime
|
- faceai-runtime:/data/runtime
|
||||||
|
- /srv/faceai/logs:/data/logs
|
||||||
- /srv/faceai/pkl:/data/pkl:ro
|
- /srv/faceai/pkl:/data/pkl:ro
|
||||||
- /srv/faceai/bin/Face_Recognition_Unix:/opt/face-recognition:ro
|
- /srv/faceai/bin/Face_Recognition_Unix:/opt/face-recognition:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
@ -211,6 +275,7 @@ Shared application settings:
|
||||||
| `FACEAI_REDIS_URL` | yes | `redis://redis:6379` | queue and search-state backend |
|
| `FACEAI_REDIS_URL` | yes | `redis://redis:6379` | queue and search-state backend |
|
||||||
| `FACEAI_QUEUE_NAME` | optional | `faceai-searches` | BullMQ queue name |
|
| `FACEAI_QUEUE_NAME` | optional | `faceai-searches` | BullMQ queue name |
|
||||||
| `FACEAI_RUNTIME_ROOT` | yes | `/data/runtime` | shared writable runtime root between site and processor |
|
| `FACEAI_RUNTIME_ROOT` | yes | `/data/runtime` | shared writable runtime root between site and processor |
|
||||||
|
| `FACEAI_LOG_ROOT` | recommended | `/data/logs` | persistent host-mounted diagnostics root for backend, processor, and per-search logs |
|
||||||
| `FACEAI_SHARED_SECRET` | yes | long random secret | trust boundary between FaceAI and the legacy bridge |
|
| `FACEAI_SHARED_SECRET` | yes | long random secret | trust boundary between FaceAI and the legacy bridge |
|
||||||
|
|
||||||
Public site settings:
|
Public site settings:
|
||||||
|
|
@ -230,11 +295,22 @@ Processor settings:
|
||||||
| Variable | Required | Example | Purpose |
|
| Variable | Required | Example | Purpose |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `FACEAI_PKL_ROOT` | yes | `/data/pkl` | mounted race-to-PKL dataset root |
|
| `FACEAI_PKL_ROOT` | yes | `/data/pkl` | mounted race-to-PKL dataset root |
|
||||||
| `FACEAI_TEST_PKL_ROOT` | optional | `/data/pkl/test` | local-only fallback PKL location |
|
|
||||||
| `FACEAI_MATCHER_BINARY` | yes | `/opt/face-recognition/face_matcher` | matcher executable inside the processor container |
|
| `FACEAI_MATCHER_BINARY` | yes | `/opt/face-recognition/face_matcher` | matcher executable inside the processor container |
|
||||||
| `FACEAI_WORKER_CONCURRENCY` | optional | `2` | BullMQ worker concurrency |
|
| `FACEAI_WORKER_CONCURRENCY` | optional | `2` | BullMQ worker concurrency |
|
||||||
| `FACEAI_WORKER_TIMEOUT_MS` | optional | `300000` | matcher timeout in milliseconds |
|
| `FACEAI_WORKER_TIMEOUT_MS` | optional | `300000` | matcher timeout in milliseconds |
|
||||||
|
|
||||||
|
The mounted PKL root is expected to use this structure:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/pkl/
|
||||||
|
2026/
|
||||||
|
04.APRILE/
|
||||||
|
PISA/
|
||||||
|
any-file-name.pkl
|
||||||
|
```
|
||||||
|
|
||||||
|
The public FaceAI site mounts the same path read-only so it can check availability during session bootstrap and refuse uploads immediately when the race has no `.pkl` data.
|
||||||
|
|
||||||
Do not enable `FACEAI_ENABLE_LOCAL_LEGACY_STATIC` in production. That mode exists only for local simulator flows.
|
Do not enable `FACEAI_ENABLE_LOCAL_LEGACY_STATIC` in production. That mode exists only for local simulator flows.
|
||||||
|
|
||||||
### Legacy-Side Configuration That Must Match
|
### Legacy-Side Configuration That Must Match
|
||||||
|
|
@ -276,7 +352,7 @@ This scaffold can now be deployed with the public site, processor, and Redis, bu
|
||||||
|
|
||||||
- search state is short-lived in Redis and is not backed by a durable database
|
- search state is short-lived in Redis and is not backed by a durable database
|
||||||
- runtime uploads and matcher output still need an agreed production retention and cleanup policy
|
- runtime uploads and matcher output still need an agreed production retention and cleanup policy
|
||||||
- the final production PKL/NAS layout is not yet locked down
|
- the PKL mount contract is now defined, but final NAS operations and cleanup policy still need to be hardened
|
||||||
- the backend currently sets the FaceAI session cookie with `secure: false`, which should be hardened before final public rollout
|
- the backend currently sets the FaceAI session cookie with `secure: false`, which should be hardened before final public rollout
|
||||||
- the local simulator endpoints under `/dev/*` are still present in the app and should be treated as non-production scaffolding
|
- the local simulator endpoints under `/dev/*` are still present in the app and should be treated as non-production scaffolding
|
||||||
- the processor CSV parser is still based on the current scaffolded matcher output assumptions
|
- the processor CSV parser is still based on the current scaffolded matcher output assumptions
|
||||||
|
|
@ -298,8 +374,8 @@ FACEAI_REDIS_URL=redis://redis:6379
|
||||||
FACEAI_QUEUE_NAME=faceai-searches
|
FACEAI_QUEUE_NAME=faceai-searches
|
||||||
FACEAI_RUNTIME_ROOT=/data/runtime
|
FACEAI_RUNTIME_ROOT=/data/runtime
|
||||||
FACEAI_UPLOAD_ROOT=/data/runtime/uploads
|
FACEAI_UPLOAD_ROOT=/data/runtime/uploads
|
||||||
|
FACEAI_LOG_ROOT=/data/logs
|
||||||
FACEAI_PKL_ROOT=/data/pkl
|
FACEAI_PKL_ROOT=/data/pkl
|
||||||
FACEAI_TEST_PKL_ROOT=/data/pkl/test
|
|
||||||
FACEAI_MATCHER_BINARY=/opt/face-recognition/face_matcher
|
FACEAI_MATCHER_BINARY=/opt/face-recognition/face_matcher
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -311,6 +387,16 @@ In the provided Docker Compose stack, that wiring is already done with:
|
||||||
FACEAI_LEGACY_RETURN_URL=http://localhost:8080/faceai_return.php
|
FACEAI_LEGACY_RETURN_URL=http://localhost:8080/faceai_return.php
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The log wiring is also already done in the checked-in Compose file with a host bind mount for `./logs:/data/logs`, so both the backend and the processor write persistent diagnostics into the workspace.
|
||||||
|
|
||||||
|
The local PHP simulator also needs the legacy bridge feature flag enabled:
|
||||||
|
|
||||||
|
```text
|
||||||
|
FACEAI_FEATURE_ENABLED=1
|
||||||
|
```
|
||||||
|
|
||||||
|
The checked-in `docker-compose.yml` now sets that on the `legacy-php` service so the simulator can launch the FaceAI handoff flow locally.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Search orchestration now uses Redis and a dedicated processor worker.
|
- Search orchestration now uses Redis and a dedicated processor worker.
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ export const config = {
|
||||||
frontendUrl: process.env.FACEAI_FRONTEND_URL || 'http://localhost:5173',
|
frontendUrl: process.env.FACEAI_FRONTEND_URL || 'http://localhost:5173',
|
||||||
publicBaseUrl: process.env.FACEAI_PUBLIC_BASE_URL || 'http://localhost:3001',
|
publicBaseUrl: process.env.FACEAI_PUBLIC_BASE_URL || 'http://localhost:3001',
|
||||||
legacyReturnUrl: process.env.FACEAI_LEGACY_RETURN_URL || 'http://localhost:3001/dev/legacy/return',
|
legacyReturnUrl: process.env.FACEAI_LEGACY_RETURN_URL || 'http://localhost:3001/dev/legacy/return',
|
||||||
|
legacyHomeUrl: process.env.FACEAI_LEGACY_HOME_URL || 'http://localhost:8080/index.jsp',
|
||||||
|
pklRoot: process.env.FACEAI_PKL_ROOT || '/data/pkl',
|
||||||
enableLocalLegacyStatic: process.env.FACEAI_ENABLE_LOCAL_LEGACY_STATIC
|
enableLocalLegacyStatic: process.env.FACEAI_ENABLE_LOCAL_LEGACY_STATIC
|
||||||
? process.env.FACEAI_ENABLE_LOCAL_LEGACY_STATIC === '1'
|
? process.env.FACEAI_ENABLE_LOCAL_LEGACY_STATIC === '1'
|
||||||
: process.env.NODE_ENV !== 'production',
|
: process.env.NODE_ENV !== 'production',
|
||||||
|
|
|
||||||
130
faceai/apps/backend/src/race-storage.js
Normal file
130
faceai/apps/backend/src/race-storage.js
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const ITALIAN_MONTH_NAMES = [
|
||||||
|
'GENNAIO',
|
||||||
|
'FEBBRAIO',
|
||||||
|
'MARZO',
|
||||||
|
'APRILE',
|
||||||
|
'MAGGIO',
|
||||||
|
'GIUGNO',
|
||||||
|
'LUGLIO',
|
||||||
|
'AGOSTO',
|
||||||
|
'SETTEMBRE',
|
||||||
|
'OTTOBRE',
|
||||||
|
'NOVEMBRE',
|
||||||
|
'DICEMBRE'
|
||||||
|
];
|
||||||
|
|
||||||
|
function sanitizePathSegment(value) {
|
||||||
|
const normalized = String(value || '').trim();
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === '.' || normalized === '..' || normalized.includes('..') || /[\\/]/.test(normalized)) {
|
||||||
|
throw new Error('Invalid race storage path segment');
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRaceFolderName(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/[<>:"/\\|?*]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMonthFolder(year, monthIndex) {
|
||||||
|
const safeYear = sanitizePathSegment(year);
|
||||||
|
const normalizedMonthIndex = Number(monthIndex);
|
||||||
|
if (!safeYear || Number.isNaN(normalizedMonthIndex) || normalizedMonthIndex < 1 || normalizedMonthIndex > 12) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${String(normalizedMonthIndex).padStart(2, '0')}.${ITALIAN_MONTH_NAMES[normalizedMonthIndex - 1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRaceStorage(storageInput = {}) {
|
||||||
|
const year = sanitizePathSegment(storageInput.year);
|
||||||
|
const monthFolder = sanitizePathSegment(storageInput.monthFolder);
|
||||||
|
const raceFolder = sanitizePathSegment(normalizeRaceFolderName(storageInput.raceFolder));
|
||||||
|
|
||||||
|
if (!year || !monthFolder || !raceFolder) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
monthFolder,
|
||||||
|
raceFolder,
|
||||||
|
relativeDir: path.posix.join(year, monthFolder, raceFolder)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveRacePklAvailability({ pklRoot, race }) {
|
||||||
|
if (!pklRoot) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
reasonCode: 'PKL_ROOT_NOT_CONFIGURED',
|
||||||
|
message: 'The PKL root is not configured for this FaceAI environment.',
|
||||||
|
storage: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = buildRaceStorage(race?.storage || race);
|
||||||
|
if (!storage) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
reasonCode: 'MISSING_RACE_STORAGE',
|
||||||
|
message: 'The legacy handoff did not provide the folder metadata required to resolve FaceAI data for this race.',
|
||||||
|
storage: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const raceDir = path.join(pklRoot, storage.year, storage.monthFolder, storage.raceFolder);
|
||||||
|
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(raceDir, { withFileTypes: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT') {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
reasonCode: 'RACE_DIRECTORY_NOT_FOUND',
|
||||||
|
message: `No FaceAI dataset directory exists for ${storage.relativeDir}.`,
|
||||||
|
storage,
|
||||||
|
raceDir
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pklEntry = entries
|
||||||
|
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.pkl'))
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name, 'en'))[0];
|
||||||
|
|
||||||
|
if (!pklEntry) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
reasonCode: 'PKL_FILE_NOT_FOUND',
|
||||||
|
message: `The race directory ${storage.relativeDir} exists, but it does not contain any .pkl file.`,
|
||||||
|
storage,
|
||||||
|
raceDir
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
reasonCode: null,
|
||||||
|
message: `Using ${storage.relativeDir}/${pklEntry.name}`,
|
||||||
|
storage,
|
||||||
|
raceDir,
|
||||||
|
pklPath: path.join(raceDir, pklEntry.name),
|
||||||
|
pklFileName: pklEntry.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -100,12 +100,13 @@ export async function markSearchProcessing(redis, searchId, ttlSeconds = 24 * 60
|
||||||
}), ttlSeconds);
|
}), ttlSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markSearchCompleted(redis, searchId, resultId, matchCount, ttlSeconds) {
|
export async function markSearchCompleted(redis, searchId, resultId, matchCount, ttlSeconds, metadata = {}) {
|
||||||
return updateSearchRecord(redis, searchId, (current) => ({
|
return updateSearchRecord(redis, searchId, (current) => ({
|
||||||
...current,
|
...current,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
resultId,
|
resultId,
|
||||||
matchCount,
|
matchCount,
|
||||||
|
completionCode: metadata.completionCode || null,
|
||||||
completedAt: Date.now()
|
completedAt: Date.now()
|
||||||
}), ttlSeconds);
|
}), ttlSeconds);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
import { signPayload, verifySignedPayload } from './auth.js';
|
import { signPayload, verifySignedPayload } from './auth.js';
|
||||||
import { createSession, getSession, mockCatalog } from './store.js';
|
import { createSession, getSession, mockCatalog } from './store.js';
|
||||||
|
import { buildRaceStorage, resolveRacePklAvailability } from './race-storage.js';
|
||||||
import {
|
import {
|
||||||
acquireActiveSearchLock,
|
acquireActiveSearchLock,
|
||||||
createRedisConnection,
|
createRedisConnection,
|
||||||
|
|
@ -67,7 +68,10 @@ function getFaceAiSession(req) {
|
||||||
function requireSession(req, res, next) {
|
function requireSession(req, res, next) {
|
||||||
const session = getFaceAiSession(req);
|
const session = getFaceAiSession(req);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
res.status(401).json({ error: 'Not authenticated with FaceAI' });
|
res.status(401).json({
|
||||||
|
error: 'Not authenticated with FaceAI',
|
||||||
|
redirectUrl: config.legacyHomeUrl
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,8 +98,24 @@ async function enforceSearchRateLimit(req, res, next) {
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
function issueHandoffToken({ raceId, raceSlug, lang, returnUrl }) {
|
function normalizeRaceForSession(raceInput) {
|
||||||
const race = mockCatalog[raceId] || { id: raceId, slug: raceSlug || `race-${raceId}`, name: raceSlug || `Race ${raceId}` };
|
return {
|
||||||
|
...raceInput,
|
||||||
|
storage: buildRaceStorage(raceInput?.storage || {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildRaceAvailability(race) {
|
||||||
|
return resolveRacePklAvailability({ pklRoot: config.pklRoot, race });
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueHandoffToken({ raceId, raceSlug, raceName, raceStorage, lang, returnUrl }) {
|
||||||
|
const race = mockCatalog[raceId] || {
|
||||||
|
id: raceId,
|
||||||
|
slug: raceSlug || `race-${raceId}`,
|
||||||
|
name: raceName || raceSlug || `Race ${raceId}`,
|
||||||
|
storage: buildRaceStorage(raceStorage || {})
|
||||||
|
};
|
||||||
|
|
||||||
return signPayload({
|
return signPayload({
|
||||||
type: 'handoff',
|
type: 'handoff',
|
||||||
|
|
@ -108,7 +128,8 @@ function issueHandoffToken({ raceId, raceSlug, lang, returnUrl }) {
|
||||||
race: {
|
race: {
|
||||||
id: race.id,
|
id: race.id,
|
||||||
slug: race.slug,
|
slug: race.slug,
|
||||||
name: race.name
|
name: race.name,
|
||||||
|
storage: buildRaceStorage(raceStorage || race.storage || {})
|
||||||
},
|
},
|
||||||
lang: lang || 'it',
|
lang: lang || 'it',
|
||||||
returnUrl,
|
returnUrl,
|
||||||
|
|
@ -231,9 +252,21 @@ app.get('/dev/legacy/race', (req, res) => {
|
||||||
app.get('/dev/legacy/launch', (req, res) => {
|
app.get('/dev/legacy/launch', (req, res) => {
|
||||||
const raceId = String(req.query.raceId || '101');
|
const raceId = String(req.query.raceId || '101');
|
||||||
const raceSlug = String(req.query.raceSlug || mockCatalog[raceId]?.slug || `race-${raceId}`);
|
const raceSlug = String(req.query.raceSlug || mockCatalog[raceId]?.slug || `race-${raceId}`);
|
||||||
|
const raceName = String(req.query.raceName || mockCatalog[raceId]?.name || raceSlug);
|
||||||
const lang = String(req.query.lang || 'it');
|
const lang = String(req.query.lang || 'it');
|
||||||
const returnUrl = String(req.query.returnUrl || `${config.publicBaseUrl}/dev/legacy/race?raceId=${encodeURIComponent(raceId)}&lang=${encodeURIComponent(lang)}`);
|
const returnUrl = String(req.query.returnUrl || `${config.publicBaseUrl}/dev/legacy/race?raceId=${encodeURIComponent(raceId)}&lang=${encodeURIComponent(lang)}`);
|
||||||
const token = issueHandoffToken({ raceId, raceSlug, lang, returnUrl });
|
const token = issueHandoffToken({
|
||||||
|
raceId,
|
||||||
|
raceSlug,
|
||||||
|
raceName,
|
||||||
|
raceStorage: {
|
||||||
|
year: String(req.query.raceYear || mockCatalog[raceId]?.storage?.year || ''),
|
||||||
|
monthFolder: String(req.query.raceMonthFolder || mockCatalog[raceId]?.storage?.monthFolder || ''),
|
||||||
|
raceFolder: String(req.query.raceFolder || mockCatalog[raceId]?.storage?.raceFolder || '')
|
||||||
|
},
|
||||||
|
lang,
|
||||||
|
returnUrl
|
||||||
|
});
|
||||||
res.redirect(`${config.frontendUrl}/auth/callback?token=${encodeURIComponent(token)}`);
|
res.redirect(`${config.frontendUrl}/auth/callback?token=${encodeURIComponent(token)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -261,7 +294,7 @@ app.get('/dev/legacy/return', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/auth/exchange', (req, res) => {
|
app.post('/api/auth/exchange', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { token } = req.body;
|
const { token } = req.body;
|
||||||
const payload = verifySignedPayload(token, config.sharedSecret);
|
const payload = verifySignedPayload(token, config.sharedSecret);
|
||||||
|
|
@ -269,13 +302,18 @@ app.post('/api/auth/exchange', (req, res) => {
|
||||||
throw new Error('Wrong token type');
|
throw new Error('Wrong token type');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const race = normalizeRaceForSession(payload.race);
|
||||||
|
const availability = await buildRaceAvailability(race);
|
||||||
|
const faceAiAllowed = payload.user.membershipStatus === 'active' && availability.available;
|
||||||
|
|
||||||
const sessionId = createSession({
|
const sessionId = createSession({
|
||||||
user: payload.user,
|
user: payload.user,
|
||||||
race: payload.race,
|
race,
|
||||||
lang: payload.lang,
|
lang: payload.lang,
|
||||||
returnUrl: payload.returnUrl,
|
returnUrl: payload.returnUrl,
|
||||||
|
availability,
|
||||||
access: {
|
access: {
|
||||||
faceAiAllowed: payload.user.membershipStatus === 'active'
|
faceAiAllowed
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -288,11 +326,12 @@ app.post('/api/auth/exchange', (req, res) => {
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
user: payload.user,
|
user: payload.user,
|
||||||
race: payload.race,
|
race,
|
||||||
lang: payload.lang,
|
lang: payload.lang,
|
||||||
returnUrl: payload.returnUrl,
|
returnUrl: payload.returnUrl,
|
||||||
|
availability,
|
||||||
access: {
|
access: {
|
||||||
faceAiAllowed: true
|
faceAiAllowed
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -308,6 +347,19 @@ app.post('/api/searches', requireSession, enforceSearchRateLimit, upload.single(
|
||||||
try {
|
try {
|
||||||
const raceId = String(req.body.raceId || req.faceaiSession.race.id);
|
const raceId = String(req.body.raceId || req.faceaiSession.race.id);
|
||||||
const userId = String(req.faceaiSession.user.id);
|
const userId = String(req.faceaiSession.user.id);
|
||||||
|
const race = normalizeRaceForSession(raceId === req.faceaiSession.race.id
|
||||||
|
? req.faceaiSession.race
|
||||||
|
: (mockCatalog[raceId] || req.faceaiSession.race));
|
||||||
|
const availability = await buildRaceAvailability(race);
|
||||||
|
|
||||||
|
if (!availability.available) {
|
||||||
|
res.status(409).json({
|
||||||
|
error: availability.message,
|
||||||
|
code: availability.reasonCode || 'RACE_PKL_UNAVAILABLE'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const activeSearchId = await getActiveSearchId(redis, userId);
|
const activeSearchId = await getActiveSearchId(redis, userId);
|
||||||
|
|
||||||
if (activeSearchId) {
|
if (activeSearchId) {
|
||||||
|
|
@ -327,10 +379,10 @@ app.post('/api/searches', requireSession, enforceSearchRateLimit, upload.single(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const race = mockCatalog[raceId] || req.faceaiSession.race;
|
|
||||||
const search = await createSearchRecord(redis, {
|
const search = await createSearchRecord(redis, {
|
||||||
raceId,
|
raceId,
|
||||||
raceName: race?.name || raceId,
|
raceName: race?.name || raceId,
|
||||||
|
raceStorage: race?.storage || availability.storage,
|
||||||
userId,
|
userId,
|
||||||
returnUrl: req.faceaiSession.returnUrl,
|
returnUrl: req.faceaiSession.returnUrl,
|
||||||
lang: req.faceaiSession.lang,
|
lang: req.faceaiSession.lang,
|
||||||
|
|
@ -371,6 +423,7 @@ app.post('/api/searches', requireSession, enforceSearchRateLimit, upload.single(
|
||||||
id: updatedSearch.id,
|
id: updatedSearch.id,
|
||||||
status: updatedSearch.status,
|
status: updatedSearch.status,
|
||||||
raceId: updatedSearch.raceId,
|
raceId: updatedSearch.raceId,
|
||||||
|
raceStorage: updatedSearch.raceStorage,
|
||||||
selfieName: updatedSearch.selfieName,
|
selfieName: updatedSearch.selfieName,
|
||||||
matchCount: updatedSearch.matchCount,
|
matchCount: updatedSearch.matchCount,
|
||||||
errorCode: updatedSearch.errorCode,
|
errorCode: updatedSearch.errorCode,
|
||||||
|
|
@ -396,6 +449,7 @@ app.get('/api/searches/:id', requireSession, async (req, res) => {
|
||||||
createdAt: search.createdAt,
|
createdAt: search.createdAt,
|
||||||
completedAt: search.completedAt,
|
completedAt: search.completedAt,
|
||||||
matchCount: search.matchCount || 0,
|
matchCount: search.matchCount || 0,
|
||||||
|
completionCode: search.completionCode || null,
|
||||||
errorCode: search.errorCode,
|
errorCode: search.errorCode,
|
||||||
errorMessage: search.errorMessage
|
errorMessage: search.errorMessage
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@ export const mockCatalog = {
|
||||||
id: '101',
|
id: '101',
|
||||||
slug: 'mezza-di-firenze',
|
slug: 'mezza-di-firenze',
|
||||||
name: 'Mezza di Firenze',
|
name: 'Mezza di Firenze',
|
||||||
|
storage: {
|
||||||
|
year: '2026',
|
||||||
|
monthFolder: '04.APRILE',
|
||||||
|
raceFolder: 'PISA'
|
||||||
|
},
|
||||||
photos: [
|
photos: [
|
||||||
{ id: 'f101-001', label: 'Arrivo 001', bib: '245', checkpoint: 'Arrivo', thumb: 'thumb-arrivo-001.jpg' },
|
{ id: 'f101-001', label: 'Arrivo 001', bib: '245', checkpoint: 'Arrivo', thumb: 'thumb-arrivo-001.jpg' },
|
||||||
{ id: 'f101-002', label: 'Arrivo 002', bib: '245', checkpoint: 'Arrivo', thumb: 'thumb-arrivo-002.jpg' },
|
{ id: 'f101-002', label: 'Arrivo 002', bib: '245', checkpoint: 'Arrivo', thumb: 'thumb-arrivo-002.jpg' },
|
||||||
|
|
@ -22,14 +27,33 @@ export const mockCatalog = {
|
||||||
},
|
},
|
||||||
'202': {
|
'202': {
|
||||||
id: '202',
|
id: '202',
|
||||||
slug: 'trail-del-chianti',
|
slug: 'mezza-di-pisa',
|
||||||
name: 'Trail del Chianti',
|
name: 'Mezza di Pisa',
|
||||||
|
storage: {
|
||||||
|
year: '2026',
|
||||||
|
monthFolder: '04.APRILE',
|
||||||
|
raceFolder: 'PISA'
|
||||||
|
},
|
||||||
photos: [
|
photos: [
|
||||||
{ id: 'f202-001', label: 'Bosco 001', bib: '77', checkpoint: 'Bosco', thumb: 'thumb-bosco-001.jpg' },
|
{ id: 'f202-001', label: 'Bosco 001', bib: '77', checkpoint: 'Bosco', thumb: 'thumb-bosco-001.jpg' },
|
||||||
{ id: 'f202-002', label: 'Salita 002', bib: '77', checkpoint: 'Salita', thumb: 'thumb-salita-002.jpg' },
|
{ id: 'f202-002', label: 'Salita 002', bib: '77', checkpoint: 'Salita', thumb: 'thumb-salita-002.jpg' },
|
||||||
{ id: 'f202-003', label: 'Arrivo 003', bib: '77', checkpoint: 'Arrivo', thumb: 'thumb-arrivo-003.jpg' },
|
{ id: 'f202-003', label: 'Arrivo 003', bib: '77', checkpoint: 'Arrivo', thumb: 'thumb-arrivo-003.jpg' },
|
||||||
{ id: 'f202-004', label: 'Bosco 004', bib: '19', checkpoint: 'Bosco', thumb: 'thumb-bosco-004.jpg' }
|
{ id: 'f202-004', label: 'Bosco 004', bib: '19', checkpoint: 'Bosco', thumb: 'thumb-bosco-004.jpg' }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
'303': {
|
||||||
|
id: '303',
|
||||||
|
slug: 'corsa-di-lucca',
|
||||||
|
name: 'Corsa di Lucca',
|
||||||
|
storage: {
|
||||||
|
year: '2026',
|
||||||
|
monthFolder: '04.APRILE',
|
||||||
|
raceFolder: 'LUCCA'
|
||||||
|
},
|
||||||
|
photos: [
|
||||||
|
{ id: 'f303-001', label: 'Mura 001', bib: '33', checkpoint: 'Mura', thumb: 'thumb-mura-001.jpg' },
|
||||||
|
{ id: 'f303-002', label: 'Centro 002', bib: '33', checkpoint: 'Centro', thumb: 'thumb-centro-002.jpg' }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
56
faceai/apps/frontend/src/components/FaceAiFeedbackPanel.vue
Normal file
56
faceai/apps/frontend/src/components/FaceAiFeedbackPanel.vue
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
statusLabel: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
isWorking: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
busyLabel: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
activeSearch: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
redirectUrl: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
errorMessage: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
t: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="faceai-feedback mt-4">
|
||||||
|
<p class="lead mb-2">{{ statusLabel }}</p>
|
||||||
|
<p v-if="activeSearch" class="mb-2">{{ t('matchesLabel') }}: {{ activeSearch.matchCount }}</p>
|
||||||
|
<p v-if="redirectUrl" class="mb-2">{{ t('redirectMessage') }}</p>
|
||||||
|
<p v-if="errorMessage" class="text-danger mb-2">{{ errorMessage }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.faceai-feedback {
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
background: linear-gradient(180deg, #fffdf9, #f5efe5);
|
||||||
|
border: 1px solid rgba(212, 189, 154, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.faceai-feedback {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
110
faceai/apps/frontend/src/components/FaceAiHeroCard.vue
Normal file
110
faceai/apps/frontend/src/components/FaceAiHeroCard.vue
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
session: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
currentLocale: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
activeSearchStatusLabel: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
t: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="faceai-hero card border-0 shadow-sm">
|
||||||
|
<div class="faceai-hero-body">
|
||||||
|
<p class="faceai-kicker mb-2">{{ t('pageTitle') }}</p>
|
||||||
|
<h1 class="faceai-title mb-3">{{ t('pageHeadline') }}</h1>
|
||||||
|
<p class="faceai-intro mb-4">{{ t('pageIntro') }}</p>
|
||||||
|
|
||||||
|
<div class="faceai-summary-grid">
|
||||||
|
<div class="faceai-summary-pill">
|
||||||
|
<span class="faceai-summary-label">{{ t('raceLabel') }}</span>
|
||||||
|
<strong>{{ session ? session.race.name : t('raceFallback') }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="faceai-summary-pill">
|
||||||
|
<span class="faceai-summary-label">{{ t('languageLabel') }}</span>
|
||||||
|
<strong>{{ session ? session.lang.toUpperCase() : currentLocale.toUpperCase() }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="faceai-summary-pill">
|
||||||
|
<span class="faceai-summary-label">{{ t('statusLabel') }}</span>
|
||||||
|
<strong>{{ activeSearchStatusLabel }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="faceai-summary-pill">
|
||||||
|
<span class="faceai-summary-label">{{ t('userLabel') }}</span>
|
||||||
|
<strong>{{ session ? session.user.displayName : t('userFallback') }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.faceai-hero {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 28px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(244, 190, 92, 0.28), transparent 32%),
|
||||||
|
linear-gradient(135deg, #fffaf1 0%, #f3ebdc 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-hero-body {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-kicker {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #9a6a19;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-title {
|
||||||
|
font-size: clamp(2rem, 4vw, 3rem);
|
||||||
|
line-height: 1.05;
|
||||||
|
color: #2d241c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-intro {
|
||||||
|
color: #665548;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-summary-pill {
|
||||||
|
padding: 0.95rem 1rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
border: 1px solid rgba(191, 158, 117, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-summary-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #8b775f;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.faceai-hero-body {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
394
faceai/apps/frontend/src/components/FaceAiUploadPanel.vue
Normal file
394
faceai/apps/frontend/src/components/FaceAiUploadPanel.vue
Normal file
|
|
@ -0,0 +1,394 @@
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
isWorking: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
isProcessingSearch: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
simulatorUrl: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
busyLabel: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
canPickFile: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
isDragging: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
selectedFile: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
selectedFileSizeLabel: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
canStartSearch: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
fileInput: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
t: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'open-file-picker',
|
||||||
|
'file-change',
|
||||||
|
'drag-enter',
|
||||||
|
'drag-over',
|
||||||
|
'drag-leave',
|
||||||
|
'drop',
|
||||||
|
'clear-file',
|
||||||
|
'submit-search'
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="faceai-panel shadow-sm">
|
||||||
|
<div v-if="loading" class="faceai-loading-state">
|
||||||
|
<span class="spinner-border text-warning" role="status" aria-hidden="true"></span>
|
||||||
|
<span>{{ busyLabel }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!session" class="faceai-empty-state">
|
||||||
|
<h2 class="faceai-section-title">{{ t('pageTitle') }}</h2>
|
||||||
|
<p class="mb-3">{{ t('handoffMissing') }}</p>
|
||||||
|
<a class="btn btn-warning" :href="simulatorUrl">{{ t('openSimulator') }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="faceai-panel-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="faceai-section-title mb-2">{{ t('uploaderTitle') }}</h2>
|
||||||
|
<p class="faceai-panel-subtitle mb-0">{{ t('uploaderHint') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isWorking && busyLabel" class="faceai-busy-banner" aria-live="polite">
|
||||||
|
<span class="faceai-spinner" role="status" aria-hidden="true"></span>
|
||||||
|
<span>{{ busyLabel }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="faceai-dropzone"
|
||||||
|
:class="{
|
||||||
|
'is-dragging': isDragging,
|
||||||
|
'is-disabled': !canPickFile,
|
||||||
|
'has-file': selectedFile,
|
||||||
|
'is-processing': isProcessingSearch
|
||||||
|
}"
|
||||||
|
@click="emit('open-file-picker')"
|
||||||
|
@dragenter="emit('drag-enter', $event)"
|
||||||
|
@dragover="emit('drag-over', $event)"
|
||||||
|
@dragleave="emit('drag-leave', $event)"
|
||||||
|
@drop="emit('drop', $event)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
class="d-none"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
:disabled="!canPickFile"
|
||||||
|
@change="emit('file-change', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="faceai-dropzone-inner">
|
||||||
|
<div class="faceai-dropzone-icon">
|
||||||
|
<i class="fa fa-cloud-upload" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="selectedFile">
|
||||||
|
<p class="faceai-dropzone-title mb-2">{{ t('uploaderSelected') }}</p>
|
||||||
|
<strong class="faceai-file-name">{{ selectedFile.name }}</strong>
|
||||||
|
<p class="faceai-file-meta mb-0">{{ selectedFileSizeLabel }}</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<p class="faceai-dropzone-title mb-2">
|
||||||
|
{{ isDragging ? t('uploaderDragActive') : t('uploaderDragIdle') }}
|
||||||
|
</p>
|
||||||
|
<p class="faceai-dropzone-copy mb-0">
|
||||||
|
{{ canPickFile ? t('uploaderFormats') : t('dropzoneDisabled') }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="faceai-dropzone-actions" @click.stop>
|
||||||
|
<button class="btn btn-outline-warning" type="button" :disabled="!canPickFile" @click="emit('open-file-picker')">
|
||||||
|
{{ selectedFile ? t('uploaderReplace') : t('uploaderBrowse') }}
|
||||||
|
</button>
|
||||||
|
<button v-if="selectedFile" class="btn btn-link" type="button" @click="emit('clear-file')">
|
||||||
|
{{ t('uploaderRemove') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isDragging" class="faceai-dropzone-overlay">
|
||||||
|
<span>{{ t('uploaderDragActive') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isProcessingSearch" class="faceai-processing-overlay" aria-live="polite" aria-busy="true">
|
||||||
|
<span class="faceai-spinner faceai-spinner-lg" role="status" aria-hidden="true"></span>
|
||||||
|
<strong>{{ busyLabel }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="faceai-action-row">
|
||||||
|
<button v-if="selectedFile" class="btn btn-warning" type="button" :disabled="!canStartSearch" @click="emit('submit-search')">
|
||||||
|
{{ t('uploadButton') }}
|
||||||
|
</button>
|
||||||
|
<a class="btn btn-light" :href="session.returnUrl">{{ t('backButton') }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!selectedFile && canPickFile" class="faceai-subtle-note">
|
||||||
|
{{ t('noFileCta') }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.faceai-panel {
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: #fffdf9;
|
||||||
|
border: 1px solid rgba(212, 189, 154, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
color: #30261e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-panel-subtitle,
|
||||||
|
.faceai-dropzone-copy,
|
||||||
|
.faceai-subtle-note {
|
||||||
|
color: #665548;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-busy-banner {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 248, 224, 0.95);
|
||||||
|
color: #744500;
|
||||||
|
border: 1px solid rgba(213, 138, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-spinner {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(191, 158, 117, 0.3);
|
||||||
|
border-top-color: #c87800;
|
||||||
|
animation: faceai-spin 0.75s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-spinner-lg {
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
border-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-loading-state,
|
||||||
|
.faceai-empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-dropzone {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 2px dashed rgba(187, 144, 72, 0.55);
|
||||||
|
background: linear-gradient(180deg, rgba(255, 248, 235, 0.95), rgba(252, 244, 230, 0.98));
|
||||||
|
min-height: 280px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-dropzone.is-processing {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-dropzone:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 18px 38px rgba(93, 72, 44, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-dropzone.is-dragging {
|
||||||
|
border-color: #d58a00;
|
||||||
|
background: linear-gradient(180deg, #fff4d7, #ffe7a8);
|
||||||
|
box-shadow: 0 20px 45px rgba(213, 138, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-dropzone.is-disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-dropzone.has-file {
|
||||||
|
border-style: solid;
|
||||||
|
background: linear-gradient(180deg, #fffaf0, #f8efe0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-dropzone-inner {
|
||||||
|
min-height: 190px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-dropzone-icon {
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
color: #b87014;
|
||||||
|
font-size: 2rem;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(191, 158, 117, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-dropzone-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2d241c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-file-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #2d241c;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-file-meta {
|
||||||
|
color: #7b6857;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-dropzone-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-dropzone-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(255, 236, 184, 0.84);
|
||||||
|
color: #764300;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-processing-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.9rem;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(255, 249, 238, 0.9);
|
||||||
|
color: #5e3800;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-action-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-subtle-note {
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.faceai-panel {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.faceai-dropzone {
|
||||||
|
min-height: 240px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-action-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-action-row .btn,
|
||||||
|
.faceai-empty-state .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-dropzone-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faceai-dropzone-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes faceai-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,9 +1,19 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
import { legacyAsset } from '../legacyAssets.js';
|
import { legacyAsset } from '../legacyAssets.js';
|
||||||
|
|
||||||
const logoUrl = legacyAsset('/images/layout/regalami-un-sorriso-ets-640.png');
|
const logoUrl = legacyAsset('/images/layout/regalami-un-sorriso-ets-640.png');
|
||||||
const facebookUrl = legacyAsset('/images/FB-f-Logo__blue_29.png');
|
const facebookUrl = legacyAsset('/images/FB-f-Logo__blue_29.png');
|
||||||
const donateUrl = legacyAsset('/images/btn_donateCC_LG.gif');
|
const donateUrl = legacyAsset('/images/btn_donateCC_LG.gif');
|
||||||
|
const isMenuOpen = ref(false);
|
||||||
|
|
||||||
|
function toggleMenu() {
|
||||||
|
isMenuOpen.value = !isMenuOpen.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
isMenuOpen.value = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -14,25 +24,32 @@ const donateUrl = legacyAsset('/images/btn_donateCC_LG.gif');
|
||||||
<a class="navbar-brand" href="http://localhost:8080/faceai_simulator.php?raceId=101&lang=it">
|
<a class="navbar-brand" href="http://localhost:8080/faceai_simulator.php?raceId=101&lang=it">
|
||||||
<img :src="logoUrl" alt="Regalami Un Sorriso ETS" width="100" />
|
<img :src="logoUrl" alt="Regalami Un Sorriso ETS" width="100" />
|
||||||
</a>
|
</a>
|
||||||
<button class="navbar-toggler navbar-toggler-right" type="button">
|
<button
|
||||||
|
class="navbar-toggler navbar-toggler-right"
|
||||||
|
type="button"
|
||||||
|
aria-controls="navbarResponsive"
|
||||||
|
:aria-expanded="isMenuOpen ? 'true' : 'false'"
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
@click="toggleMenu"
|
||||||
|
>
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse navbar-collapse show" id="navbarResponsive">
|
<div :class="['collapse', 'navbar-collapse', { show: isMenuOpen }]" id="navbarResponsive">
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="http://localhost:8080/index.jsp">Home</a>
|
<a class="nav-link" href="http://localhost:8080/index.jsp" @click="closeMenu">Home</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="http://localhost:8080/associazione.jsp">Associazione</a>
|
<a class="nav-link" href="http://localhost:8080/associazione.jsp" @click="closeMenu">Associazione</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link active" href="http://localhost:8080/faceai_simulator.php?raceId=101&lang=it">Foto</a>
|
<a class="nav-link active" href="http://localhost:8080/faceai_simulator.php?raceId=101&lang=it" @click="closeMenu">Foto</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link btn btn-sm btn-warning" href="http://localhost:8080/gallery2.php">Archivio</a>
|
<a class="nav-link btn btn-sm btn-warning" href="http://localhost:8080/gallery2.php" @click="closeMenu">Archivio</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a href="http://localhost:8080/dettaglio_clienti-it.html">
|
<a href="http://localhost:8080/dettaglio_clienti-it.html" @click="closeMenu">
|
||||||
<img :src="donateUrl" border="0" alt="PayPal" />
|
<img :src="donateUrl" border="0" alt="PayPal" />
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -40,12 +57,12 @@ const donateUrl = legacyAsset('/images/btn_donateCC_LG.gif');
|
||||||
|
|
||||||
<ul class="navbar-nav ml-auto">
|
<ul class="navbar-nav ml-auto">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link active" href="#">
|
<a class="nav-link active" href="#" @click="closeMenu">
|
||||||
<i class="fa fa-user" aria-hidden="true"></i> Il mio account
|
<i class="fa fa-user" aria-hidden="true"></i> Il mio account
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="https://it-it.facebook.com/pg/Regalami-un-sorriso-ETS-189377806523/community/">
|
<a class="nav-link" href="https://it-it.facebook.com/pg/Regalami-un-sorriso-ETS-189377806523/community/" @click="closeMenu">
|
||||||
<img :src="facebookUrl" class="img-fluid" alt="Facebook" />
|
<img :src="facebookUrl" class="img-fluid" alt="Facebook" />
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
533
faceai/apps/frontend/src/composables/useFaceAiHome.js
Normal file
533
faceai/apps/frontend/src/composables/useFaceAiHome.js
Normal file
|
|
@ -0,0 +1,533 @@
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
it: {
|
||||||
|
pageTitle: 'Face ID',
|
||||||
|
pageHeadline: 'Trova le tue foto con un selfie',
|
||||||
|
pageIntro: 'Carica una tua immagine recente e lascia che Face ID cerchi le corrispondenze solo nella gara corrente.',
|
||||||
|
userFallback: 'Sessione FaceAI',
|
||||||
|
raceFallback: 'Gara corrente',
|
||||||
|
statusReady: 'Pronto',
|
||||||
|
statusProcessing: 'In lavorazione',
|
||||||
|
statusCompleted: 'Completata',
|
||||||
|
statusFailed: 'Errore',
|
||||||
|
statusLabel: 'Stato',
|
||||||
|
userLabel: 'Utente',
|
||||||
|
raceLabel: 'Gara',
|
||||||
|
languageLabel: 'Lingua',
|
||||||
|
uploaderTitle: 'Carica il tuo selfie',
|
||||||
|
uploaderHint: 'Puoi trascinare un file immagine oppure selezionarlo dal dispositivo.',
|
||||||
|
uploaderDragIdle: 'Trascina qui il selfie',
|
||||||
|
uploaderDragActive: 'Rilascia l’immagine per caricarla',
|
||||||
|
uploaderBrowse: 'Scegli immagine',
|
||||||
|
uploaderFormats: 'Formati supportati: JPG, PNG, WEBP',
|
||||||
|
uploaderSelected: 'File selezionato',
|
||||||
|
uploaderReplace: 'Sostituisci',
|
||||||
|
uploaderRemove: 'Rimuovi',
|
||||||
|
backButton: 'Torna alla pagina gara',
|
||||||
|
uploadButton: 'Avvia ricerca Face ID',
|
||||||
|
openSimulator: 'Apri il simulatore legacy',
|
||||||
|
handoffMissing: 'Apri prima il simulatore legacy per generare il token firmato di handoff.',
|
||||||
|
sessionLoading: 'Caricamento della sessione FaceAI…',
|
||||||
|
submitLoading: 'Invio del selfie e preparazione della ricerca…',
|
||||||
|
redirectLoading: 'Reindirizzamento alla pagina legacy filtrata in corso…',
|
||||||
|
processingLoading: 'Ricerca biometrica in corso su tutte le foto della gara…',
|
||||||
|
unavailableDefault: 'FaceAI non è disponibile per questa gara.',
|
||||||
|
noFacesFoundMessage: 'Nessun volto rilevato nella foto caricata. Puoi tornare alla gara oppure provare con un altro selfie.',
|
||||||
|
readyMessage: 'Seleziona un selfie per avviare una ricerca limitata alla gara corrente.',
|
||||||
|
completedMessage: 'Ricerca completata. Trovate {count} foto corrispondenti.',
|
||||||
|
failedMessage: 'La ricerca non è stata completata. Verifica il messaggio di errore e riprova.',
|
||||||
|
matchesLabel: 'Foto trovate',
|
||||||
|
redirectMessage: 'Reindirizzamento alla pagina legacy filtrata in corso…',
|
||||||
|
noFileCta: 'Seleziona un’immagine per sbloccare la ricerca.',
|
||||||
|
invalidImage: 'Seleziona un file immagine valido.',
|
||||||
|
pollError: 'Impossibile leggere lo stato della ricerca.',
|
||||||
|
searchFailed: 'La ricerca non è andata a buon fine.',
|
||||||
|
redirectError: 'Impossibile generare il link di ritorno.',
|
||||||
|
chooseSelfie: 'Seleziona un selfie prima di avviare la ricerca.',
|
||||||
|
raceDataUnavailable: 'I dati FaceAI non sono disponibili per questa gara.',
|
||||||
|
invalidRaceData: 'I dati della gara ricevuti non sono validi. Torna alla pagina gara e riapri Face ID dalla gara corretta.',
|
||||||
|
searchCreateError: 'Impossibile avviare la ricerca.',
|
||||||
|
faceAiAlt: 'FaceAI',
|
||||||
|
dropzoneDisabled: 'Il caricamento non è disponibile per questa gara.'
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
pageTitle: 'Face ID',
|
||||||
|
pageHeadline: 'Find your photos with a selfie',
|
||||||
|
pageIntro: 'Upload a recent picture of yourself and Face ID will search only within the current race.',
|
||||||
|
userFallback: 'FaceAI session',
|
||||||
|
raceFallback: 'Current race',
|
||||||
|
statusReady: 'Ready',
|
||||||
|
statusProcessing: 'Processing',
|
||||||
|
statusCompleted: 'Completed',
|
||||||
|
statusFailed: 'Error',
|
||||||
|
statusLabel: 'Status',
|
||||||
|
userLabel: 'User',
|
||||||
|
raceLabel: 'Race',
|
||||||
|
languageLabel: 'Language',
|
||||||
|
uploaderTitle: 'Upload your selfie',
|
||||||
|
uploaderHint: 'Drag an image here or choose it from your device.',
|
||||||
|
uploaderDragIdle: 'Drag your selfie here',
|
||||||
|
uploaderDragActive: 'Drop the image to upload it',
|
||||||
|
uploaderBrowse: 'Choose image',
|
||||||
|
uploaderFormats: 'Supported formats: JPG, PNG, WEBP',
|
||||||
|
uploaderSelected: 'Selected file',
|
||||||
|
uploaderReplace: 'Replace',
|
||||||
|
uploaderRemove: 'Remove',
|
||||||
|
backButton: 'Back to the race page',
|
||||||
|
uploadButton: 'Start Face ID search',
|
||||||
|
openSimulator: 'Open the legacy simulator',
|
||||||
|
handoffMissing: 'Open the legacy simulator first to generate the signed handoff token.',
|
||||||
|
sessionLoading: 'Loading the FaceAI session…',
|
||||||
|
submitLoading: 'Uploading the selfie and preparing the search…',
|
||||||
|
redirectLoading: 'Redirecting to the filtered legacy page…',
|
||||||
|
processingLoading: 'Biometric search in progress across all race photos…',
|
||||||
|
unavailableDefault: 'FaceAI is not available for this race.',
|
||||||
|
noFacesFoundMessage: 'No faces were detected in the uploaded image. You can return to the race page or try another selfie.',
|
||||||
|
readyMessage: 'Select a selfie to start a search limited to the current race.',
|
||||||
|
completedMessage: 'Search completed. Found {count} matching photos.',
|
||||||
|
failedMessage: 'The search did not complete. Check the message and try again.',
|
||||||
|
matchesLabel: 'Photos found',
|
||||||
|
redirectMessage: 'Redirecting to the filtered legacy page…',
|
||||||
|
noFileCta: 'Select an image to unlock the search action.',
|
||||||
|
invalidImage: 'Select a valid image file.',
|
||||||
|
pollError: 'Unable to read the search status.',
|
||||||
|
searchFailed: 'The search failed.',
|
||||||
|
redirectError: 'Unable to build the return link.',
|
||||||
|
chooseSelfie: 'Choose a selfie before starting the search.',
|
||||||
|
raceDataUnavailable: 'FaceAI data is not available for this race.',
|
||||||
|
invalidRaceData: 'The race data received for this session is invalid. Go back to the race page and reopen Face ID from the correct race.',
|
||||||
|
searchCreateError: 'Unable to start the search.',
|
||||||
|
faceAiAlt: 'FaceAI',
|
||||||
|
dropzoneDisabled: 'Upload is not available for this race.'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const knownServerMessages = {
|
||||||
|
'No training dataset available for this race.': 'raceDataUnavailable',
|
||||||
|
'FaceAI data is not available for this race.': 'raceDataUnavailable',
|
||||||
|
'FaceAI is not available for this race.': 'unavailableDefault',
|
||||||
|
'Unable to read search status.': 'pollError',
|
||||||
|
'The search failed.': 'searchFailed',
|
||||||
|
'Unable to build return URL.': 'redirectError',
|
||||||
|
'Unable to create the search.': 'searchCreateError',
|
||||||
|
'Choose a selfie before starting the search.': 'chooseSelfie'
|
||||||
|
};
|
||||||
|
|
||||||
|
const simulatorUrl = 'http://localhost:8080/faceai_simulator.php?raceId=101&lang=it';
|
||||||
|
const legacyHomeUrl = 'http://localhost:8080/index.jsp';
|
||||||
|
|
||||||
|
function isInvalidRaceAvailability(availability) {
|
||||||
|
return availability?.reasonCode === 'RACE_DIRECTORY_NOT_FOUND' || availability?.reasonCode === 'MISSING_RACE_STORAGE';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFaceAiHome() {
|
||||||
|
const session = ref(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const errorMessage = ref('');
|
||||||
|
const selectedFile = ref(null);
|
||||||
|
const activeSearch = ref(null);
|
||||||
|
const redirectUrl = ref('');
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
const isRedirecting = ref(false);
|
||||||
|
const isDragging = ref(false);
|
||||||
|
const fileInput = ref(null);
|
||||||
|
let pollTimer = null;
|
||||||
|
let dragDepth = 0;
|
||||||
|
|
||||||
|
const currentLocale = computed(() => {
|
||||||
|
const language = (session.value?.lang || document.documentElement.lang || 'it').toLowerCase();
|
||||||
|
return language.startsWith('en') ? 'en' : 'it';
|
||||||
|
});
|
||||||
|
|
||||||
|
function t(key, params = {}) {
|
||||||
|
const message = copy[currentLocale.value][key] || copy.it[key] || key;
|
||||||
|
return Object.keys(params).reduce((text, paramKey) => text.replace(`{${paramKey}}`, String(params[paramKey])), message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function localizeServerMessage(message, fallbackKey) {
|
||||||
|
if (!message) {
|
||||||
|
return t(fallbackKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLocale.value === 'en') {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedKey = knownServerMessages[message];
|
||||||
|
if (mappedKey) {
|
||||||
|
return t(mappedKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return t(fallbackKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvailabilityUserMessage(availability, fallbackKey = 'unavailableDefault') {
|
||||||
|
if (isInvalidRaceAvailability(availability)) {
|
||||||
|
return t('invalidRaceData');
|
||||||
|
}
|
||||||
|
|
||||||
|
return localizeServerMessage(availability?.message, fallbackKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldLogFaceAiDebug() {
|
||||||
|
return import.meta.env.DEV || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function logFaceAiDebug(label, extra = null) {
|
||||||
|
if (!shouldLogFaceAiDebug()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
pageUrl: window.location.href,
|
||||||
|
session: session.value,
|
||||||
|
availability: raceAvailability.value,
|
||||||
|
activeSearch: activeSearch.value,
|
||||||
|
redirectUrl: redirectUrl.value,
|
||||||
|
extra
|
||||||
|
};
|
||||||
|
|
||||||
|
console.groupCollapsed(`[FaceAI] ${label}`);
|
||||||
|
console.log(payload);
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportInvalidRaceAvailability(availability) {
|
||||||
|
if (!isInvalidRaceAvailability(availability)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = {
|
||||||
|
raceId: session.value?.race?.id || null,
|
||||||
|
raceName: session.value?.race?.name || null,
|
||||||
|
lang: session.value?.lang || currentLocale.value,
|
||||||
|
reasonCode: availability.reasonCode,
|
||||||
|
message: availability.message,
|
||||||
|
storage: availability.storage || null,
|
||||||
|
raceDir: availability.raceDir || null
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error(`[FaceAI] Invalid race data: ${JSON.stringify(details)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWorking = computed(() => loading.value || isSubmitting.value || isRedirecting.value || activeSearch.value?.status === 'processing');
|
||||||
|
const isProcessingSearch = computed(() => isSubmitting.value || activeSearch.value?.status === 'processing');
|
||||||
|
const raceAvailability = computed(() => session.value?.availability || null);
|
||||||
|
const canPickFile = computed(() => Boolean(session.value) && raceAvailability.value?.available === true && !isWorking.value);
|
||||||
|
|
||||||
|
const canStartSearch = computed(() => {
|
||||||
|
if (!session.value || !selectedFile.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.value.access?.faceAiAllowed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return raceAvailability.value?.available === true && !isWorking.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedFileSizeLabel = computed(() => {
|
||||||
|
if (!selectedFile.value?.size) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeInMb = selectedFile.value.size / (1024 * 1024);
|
||||||
|
if (sizeInMb >= 1) {
|
||||||
|
return `${sizeInMb.toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.max(1, Math.round(selectedFile.value.size / 1024))} KB`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeSearchStatusLabel = computed(() => {
|
||||||
|
const status = activeSearch.value?.status;
|
||||||
|
|
||||||
|
if (status === 'processing') {
|
||||||
|
return t('statusProcessing');
|
||||||
|
}
|
||||||
|
if (status === 'completed') {
|
||||||
|
return t('statusCompleted');
|
||||||
|
}
|
||||||
|
if (status === 'failed') {
|
||||||
|
return t('statusFailed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('statusReady');
|
||||||
|
});
|
||||||
|
|
||||||
|
const busyLabel = computed(() => {
|
||||||
|
if (loading.value) {
|
||||||
|
return t('sessionLoading');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSubmitting.value) {
|
||||||
|
return t('submitLoading');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRedirecting.value) {
|
||||||
|
return t('redirectLoading');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSearch.value?.status === 'processing') {
|
||||||
|
return t('processingLoading');
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusLabel = computed(() => {
|
||||||
|
if (!activeSearch.value) {
|
||||||
|
if (session.value && raceAvailability.value && !raceAvailability.value.available) {
|
||||||
|
return getAvailabilityUserMessage(raceAvailability.value, 'unavailableDefault');
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('readyMessage');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSearch.value.status === 'completed') {
|
||||||
|
if (activeSearch.value.completionCode === 'NO_FACES_FOUND') {
|
||||||
|
return t('noFacesFoundMessage');
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('completedMessage', { count: activeSearch.value.matchCount ?? 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSearch.value.status === 'failed') {
|
||||||
|
return localizeServerMessage(activeSearch.value.errorMessage, 'failedMessage');
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('processingLoading');
|
||||||
|
});
|
||||||
|
|
||||||
|
function openFilePicker() {
|
||||||
|
if (!canPickFile.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelectedFile(file) {
|
||||||
|
if (!file) {
|
||||||
|
selectedFile.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.type || !file.type.startsWith('image/')) {
|
||||||
|
selectedFile.value = null;
|
||||||
|
errorMessage.value = t('invalidImage');
|
||||||
|
if (fileInput.value) {
|
||||||
|
fileInput.value.value = '';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedFile.value = file;
|
||||||
|
errorMessage.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelectedFile() {
|
||||||
|
selectedFile.value = null;
|
||||||
|
if (fileInput.value) {
|
||||||
|
fileInput.value.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileChange(event) {
|
||||||
|
setSelectedFile(event.target.files?.[0] || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnter(event) {
|
||||||
|
if (!canPickFile.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
dragDepth += 1;
|
||||||
|
isDragging.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(event) {
|
||||||
|
if (!canPickFile.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
isDragging.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave(event) {
|
||||||
|
if (!canPickFile.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
dragDepth = Math.max(0, dragDepth - 1);
|
||||||
|
if (dragDepth === 0) {
|
||||||
|
isDragging.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(event) {
|
||||||
|
if (!canPickFile.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
dragDepth = 0;
|
||||||
|
isDragging.value = false;
|
||||||
|
setSelectedFile(event.dataTransfer?.files?.[0] || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSession() {
|
||||||
|
loading.value = true;
|
||||||
|
const response = await fetch('/api/session', { credentials: 'include' });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
loading.value = false;
|
||||||
|
logFaceAiDebug('Session load failed', { status: response.status, payload });
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
window.location.replace(payload.redirectUrl || legacyHomeUrl);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.value = await response.json();
|
||||||
|
loading.value = false;
|
||||||
|
if (session.value?.availability && !session.value.availability.available && isInvalidRaceAvailability(session.value.availability)) {
|
||||||
|
errorMessage.value = getAvailabilityUserMessage(session.value.availability, 'invalidRaceData');
|
||||||
|
reportInvalidRaceAvailability(session.value.availability);
|
||||||
|
}
|
||||||
|
logFaceAiDebug('Session loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollSearch(searchId) {
|
||||||
|
const response = await fetch(`/api/searches/${searchId}`, { credentials: 'include' });
|
||||||
|
if (!response.ok) {
|
||||||
|
errorMessage.value = t('pollError');
|
||||||
|
isSubmitting.value = false;
|
||||||
|
logFaceAiDebug('Search polling failed', { searchId, status: response.status });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeSearch.value = await response.json();
|
||||||
|
logFaceAiDebug('Search status updated', { searchId, status: activeSearch.value.status });
|
||||||
|
if (activeSearch.value.status === 'failed') {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
errorMessage.value = localizeServerMessage(activeSearch.value.errorMessage, 'searchFailed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSearch.value.status === 'completed') {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
if (activeSearch.value.completionCode === 'NO_FACES_FOUND') {
|
||||||
|
isRedirecting.value = false;
|
||||||
|
redirectUrl.value = '';
|
||||||
|
clearSelectedFile();
|
||||||
|
logFaceAiDebug('Search completed without detectable faces', { searchId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectResponse = await fetch(`/api/searches/${searchId}/redirect`, { credentials: 'include' });
|
||||||
|
const payload = await redirectResponse.json();
|
||||||
|
if (!redirectResponse.ok) {
|
||||||
|
errorMessage.value = localizeServerMessage(payload.error, 'redirectError');
|
||||||
|
logFaceAiDebug('Redirect build failed', { searchId, payload });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
redirectUrl.value = payload.url;
|
||||||
|
isRedirecting.value = true;
|
||||||
|
logFaceAiDebug('Redirect URL ready', { searchId, url: payload.url });
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.location.href = payload.url;
|
||||||
|
}, 1200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pollTimer = window.setTimeout(() => {
|
||||||
|
pollSearch(searchId);
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSearch() {
|
||||||
|
errorMessage.value = '';
|
||||||
|
redirectUrl.value = '';
|
||||||
|
isRedirecting.value = false;
|
||||||
|
|
||||||
|
if (!selectedFile.value) {
|
||||||
|
errorMessage.value = t('chooseSelfie');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.value?.access?.faceAiAllowed || !raceAvailability.value?.available) {
|
||||||
|
errorMessage.value = getAvailabilityUserMessage(raceAvailability.value, 'raceDataUnavailable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set('raceId', session.value.race.id);
|
||||||
|
formData.set('selfie', selectedFile.value);
|
||||||
|
|
||||||
|
const response = await fetch('/api/searches', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
errorMessage.value = localizeServerMessage(payload.error, 'searchCreateError');
|
||||||
|
isSubmitting.value = false;
|
||||||
|
logFaceAiDebug('Search creation failed', { status: response.status, payload });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeSearch.value = payload;
|
||||||
|
logFaceAiDebug('Search created', { payload });
|
||||||
|
pollSearch(payload.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadSession);
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (pollTimer) {
|
||||||
|
window.clearTimeout(pollTimer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeSearch,
|
||||||
|
activeSearchStatusLabel,
|
||||||
|
busyLabel,
|
||||||
|
canPickFile,
|
||||||
|
canStartSearch,
|
||||||
|
clearSelectedFile,
|
||||||
|
currentLocale,
|
||||||
|
errorMessage,
|
||||||
|
fileInput,
|
||||||
|
isDragging,
|
||||||
|
isProcessingSearch,
|
||||||
|
isRedirecting,
|
||||||
|
isSubmitting,
|
||||||
|
isWorking,
|
||||||
|
loading,
|
||||||
|
onDragEnter,
|
||||||
|
onDragLeave,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
onFileChange,
|
||||||
|
openFilePicker,
|
||||||
|
redirectUrl,
|
||||||
|
selectedFile,
|
||||||
|
selectedFileSizeLabel,
|
||||||
|
session,
|
||||||
|
simulatorUrl,
|
||||||
|
statusLabel,
|
||||||
|
submitSearch,
|
||||||
|
t
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,150 +1,39 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
||||||
import LegacyHeader from '../components/LegacyHeader.vue';
|
import LegacyHeader from '../components/LegacyHeader.vue';
|
||||||
import { legacyAsset } from '../legacyAssets.js';
|
import FaceAiFeedbackPanel from '../components/FaceAiFeedbackPanel.vue';
|
||||||
|
import FaceAiHeroCard from '../components/FaceAiHeroCard.vue';
|
||||||
|
import FaceAiUploadPanel from '../components/FaceAiUploadPanel.vue';
|
||||||
|
import { useFaceAiHome } from '../composables/useFaceAiHome.js';
|
||||||
|
|
||||||
const coverImageUrl = legacyAsset('/images/layout/Logo_RUS_ETS_tricolore_3-1.jpg');
|
const {
|
||||||
|
activeSearch,
|
||||||
const session = ref(null);
|
activeSearchStatusLabel,
|
||||||
const loading = ref(true);
|
busyLabel,
|
||||||
const errorMessage = ref('');
|
canPickFile,
|
||||||
const selectedFile = ref(null);
|
canStartSearch,
|
||||||
const activeSearch = ref(null);
|
clearSelectedFile,
|
||||||
const redirectUrl = ref('');
|
currentLocale,
|
||||||
const isSubmitting = ref(false);
|
errorMessage,
|
||||||
const isRedirecting = ref(false);
|
fileInput,
|
||||||
let pollTimer = null;
|
isDragging,
|
||||||
|
isProcessingSearch,
|
||||||
const isWorking = computed(() => loading.value || isSubmitting.value || isRedirecting.value || activeSearch.value?.status === 'processing');
|
isWorking,
|
||||||
|
loading,
|
||||||
const busyLabel = computed(() => {
|
onDragEnter,
|
||||||
if (loading.value) {
|
onDragLeave,
|
||||||
return 'Caricamento sessione FaceAI...';
|
onDragOver,
|
||||||
}
|
onDrop,
|
||||||
|
onFileChange,
|
||||||
if (isSubmitting.value) {
|
openFilePicker,
|
||||||
return 'Invio del selfie e preparazione della ricerca...';
|
redirectUrl,
|
||||||
}
|
selectedFile,
|
||||||
|
selectedFileSizeLabel,
|
||||||
if (isRedirecting.value) {
|
session,
|
||||||
return 'Reindirizzamento alla pagina legacy filtrata in corso...';
|
simulatorUrl,
|
||||||
}
|
statusLabel,
|
||||||
|
submitSearch,
|
||||||
if (activeSearch.value?.status === 'processing') {
|
t
|
||||||
return 'Ricerca biometrica in corso su tutte le foto della gara...';
|
} = useFaceAiHome();
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
});
|
|
||||||
|
|
||||||
const statusLabel = computed(() => {
|
|
||||||
if (!activeSearch.value) {
|
|
||||||
return 'Carica un selfie per avviare una ricerca limitata alla gara corrente.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeSearch.value.status === 'completed') {
|
|
||||||
return `Ricerca completata. Trovate ${activeSearch.value.matchCount} foto corrispondenti.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeSearch.value.status === 'failed') {
|
|
||||||
return 'La ricerca non e stata completata. Verifica il messaggio di errore e riprova.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Ricerca in corso. Il sistema aggiorna automaticamente lo stato finche il risultato non e pronto.';
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadSession() {
|
|
||||||
loading.value = true;
|
|
||||||
const response = await fetch('/api/session', { credentials: 'include' });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
loading.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
session.value = await response.json();
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onFileChange(event) {
|
|
||||||
selectedFile.value = event.target.files?.[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollSearch(searchId) {
|
|
||||||
const response = await fetch(`/api/searches/${searchId}`, { credentials: 'include' });
|
|
||||||
if (!response.ok) {
|
|
||||||
errorMessage.value = 'Unable to read search status.';
|
|
||||||
isSubmitting.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
activeSearch.value = await response.json();
|
|
||||||
if (activeSearch.value.status === 'failed') {
|
|
||||||
isSubmitting.value = false;
|
|
||||||
errorMessage.value = activeSearch.value.errorMessage || 'The search failed.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeSearch.value.status === 'completed') {
|
|
||||||
isSubmitting.value = false;
|
|
||||||
const redirectResponse = await fetch(`/api/searches/${searchId}/redirect`, { credentials: 'include' });
|
|
||||||
const payload = await redirectResponse.json();
|
|
||||||
if (!redirectResponse.ok) {
|
|
||||||
errorMessage.value = payload.error || 'Unable to build return URL.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
redirectUrl.value = payload.url;
|
|
||||||
isRedirecting.value = true;
|
|
||||||
window.setTimeout(() => {
|
|
||||||
window.location.href = payload.url;
|
|
||||||
}, 1200);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
pollTimer = window.setTimeout(() => {
|
|
||||||
pollSearch(searchId);
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitSearch() {
|
|
||||||
errorMessage.value = '';
|
|
||||||
redirectUrl.value = '';
|
|
||||||
isRedirecting.value = false;
|
|
||||||
|
|
||||||
if (!selectedFile.value) {
|
|
||||||
errorMessage.value = 'Choose a selfie before starting the search.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isSubmitting.value = true;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.set('raceId', session.value.race.id);
|
|
||||||
formData.set('selfie', selectedFile.value);
|
|
||||||
|
|
||||||
const response = await fetch('/api/searches', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = await response.json();
|
|
||||||
if (!response.ok) {
|
|
||||||
errorMessage.value = payload.error || 'Unable to create the search.';
|
|
||||||
isSubmitting.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
activeSearch.value = payload;
|
|
||||||
pollSearch(payload.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadSession);
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (pollTimer) {
|
|
||||||
window.clearTimeout(pollTimer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -152,92 +41,62 @@ onBeforeUnmount(() => {
|
||||||
<LegacyHeader />
|
<LegacyHeader />
|
||||||
|
|
||||||
<div class="container my-3 faceai-page">
|
<div class="container my-3 faceai-page">
|
||||||
<div class="row mb-5">
|
<FaceAiHeroCard
|
||||||
<div class="col-lg-12">
|
:session="session"
|
||||||
<h1 class="my-4">Face ID</h1>
|
:current-locale="currentLocale"
|
||||||
</div>
|
:active-search-status-label="activeSearchStatusLabel"
|
||||||
|
:t="t"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="col-md-2">
|
<div class="row mt-4">
|
||||||
<img :src="coverImageUrl" class="img-fluid border border-warning" alt="FaceAI" />
|
<div class="col-12">
|
||||||
</div>
|
<FaceAiUploadPanel
|
||||||
|
:loading="loading"
|
||||||
<div class="col-md-10">
|
:is-working="isWorking"
|
||||||
<div class="row riepilogo">
|
:is-processing-search="isProcessingSearch"
|
||||||
<div class="col-md-3">
|
:session="session"
|
||||||
<p><i class="fa fa-user fa-lg text-warning" aria-hidden="true"></i> {{ session ? session.user.displayName : 'Sessione FaceAI' }}</p>
|
:simulator-url="simulatorUrl"
|
||||||
</div>
|
:busy-label="busyLabel"
|
||||||
<div class="col-md-3">
|
:can-pick-file="canPickFile"
|
||||||
<p><i class="fa fa-camera-retro fa-lg text-warning" aria-hidden="true"></i> {{ session ? session.race.name : 'Upload selfie' }}</p>
|
:is-dragging="isDragging"
|
||||||
</div>
|
:selected-file="selectedFile"
|
||||||
<div class="col">
|
:selected-file-size-label="selectedFileSizeLabel"
|
||||||
<p><i class="fa fa-refresh fa-lg text-warning" aria-hidden="true"></i> {{ activeSearch ? activeSearch.status : 'ready' }}</p>
|
:can-start-search="canStartSearch"
|
||||||
</div>
|
:file-input="fileInput"
|
||||||
</div>
|
:t="t"
|
||||||
|
@open-file-picker="openFilePicker"
|
||||||
<div class="row">
|
@file-change="onFileChange"
|
||||||
<div class="col-12">
|
@drag-enter="onDragEnter"
|
||||||
<div class="bg-light faceai-form-shell">
|
@drag-over="onDragOver"
|
||||||
<div class="row">
|
@drag-leave="onDragLeave"
|
||||||
<div class="form-group mx-3 pt-4 pb-1 mb-0 px-2 arrow_box">
|
@drop="onDrop"
|
||||||
<h2>Cerca le tue foto</h2>
|
@clear-file="clearSelectedFile"
|
||||||
</div>
|
@submit-search="submitSearch"
|
||||||
|
/>
|
||||||
<div v-if="loading" class="col-12 p-4">
|
|
||||||
<div class="faceai-spinner-block">
|
|
||||||
<span class="spinner-border text-warning" role="status" aria-hidden="true"></span>
|
|
||||||
<span>{{ busyLabel }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="!session" class="col-12 p-4">
|
|
||||||
<p>Apri prima il simulatore legacy per generare il token firmato di handoff.</p>
|
|
||||||
<a class="btn btn-warning" href="http://localhost:8080/faceai_simulator.php?raceId=101&lang=it">Apri il simulatore legacy</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="form-group col-12 col-md-4 mt-3 ml-3">
|
|
||||||
<div class="input-group">
|
|
||||||
<label for="raceName" class="sr-only">Gara</label>
|
|
||||||
<input id="raceName" class="form-control form-control-sm mb-2 mb-sm-0" :value="session.race.name" readonly />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group col-12 col-md-3 mt-3 ml-3">
|
|
||||||
<div class="input-group">
|
|
||||||
<label for="langView" class="sr-only">Lingua</label>
|
|
||||||
<input id="langView" class="form-control form-control-sm mb-2 mb-sm-0" :value="session.lang" readonly />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group col-12 col-md-4 mt-3 ml-3">
|
|
||||||
<div class="input-group">
|
|
||||||
<label for="selfieUpload" class="sr-only">Selfie</label>
|
|
||||||
<input id="selfieUpload" class="form-control form-control-sm mb-2 mb-sm-0" type="file" accept="image/*" @change="onFileChange" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group col-12 mt-2 ml-3 mr-3 faceai-action-row">
|
|
||||||
<button class="btn btn-warning" type="button" :disabled="isWorking" @click="submitSearch">Avvia ricerca Face ID</button>
|
|
||||||
<a class="btn btn-light" :href="session.returnUrl">Torna alla pagina gara</a>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="faceai-feedback mt-4">
|
|
||||||
<p class="lead mb-2">{{ statusLabel }}</p>
|
|
||||||
<div v-if="isWorking && busyLabel" class="faceai-spinner-block mb-3">
|
|
||||||
<span class="spinner-border spinner-border-sm text-warning" role="status" aria-hidden="true"></span>
|
|
||||||
<span>{{ busyLabel }}</span>
|
|
||||||
</div>
|
|
||||||
<p v-if="activeSearch" class="mb-2">Match trovati: {{ activeSearch.matchCount }}</p>
|
|
||||||
<p v-if="redirectUrl" class="mb-2">Reindirizzamento alla pagina legacy filtrata in corso...</p>
|
|
||||||
<p v-if="errorMessage" class="text-danger mb-2">{{ errorMessage }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FaceAiFeedbackPanel
|
||||||
|
:status-label="statusLabel"
|
||||||
|
:is-working="isWorking"
|
||||||
|
:busy-label="busyLabel"
|
||||||
|
:active-search="activeSearch"
|
||||||
|
:redirect-url="redirectUrl"
|
||||||
|
:error-message="errorMessage"
|
||||||
|
:t="t"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.faceai-page {
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.faceai-page {
|
||||||
|
padding-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
redisUrl: process.env.FACEAI_REDIS_URL || 'redis://redis:6379',
|
redisUrl: process.env.FACEAI_REDIS_URL || 'redis://redis:6379',
|
||||||
queueName: process.env.FACEAI_QUEUE_NAME || 'faceai-searches',
|
queueName: process.env.FACEAI_QUEUE_NAME || 'faceai-searches',
|
||||||
workerConcurrency: Number(process.env.FACEAI_WORKER_CONCURRENCY || 2),
|
workerConcurrency: Number(process.env.FACEAI_WORKER_CONCURRENCY || 2),
|
||||||
workerTimeoutMs: Number(process.env.FACEAI_WORKER_TIMEOUT_MS || 5 * 60 * 1000),
|
workerTimeoutMs: Number(process.env.FACEAI_WORKER_TIMEOUT_MS || 5 * 60 * 1000),
|
||||||
runtimeRoot: process.env.FACEAI_RUNTIME_ROOT || '/data/runtime',
|
runtimeRoot: process.env.FACEAI_RUNTIME_ROOT || '/data/runtime',
|
||||||
|
logRoot: process.env.FACEAI_LOG_ROOT || path.join(process.env.FACEAI_RUNTIME_ROOT || '/data/runtime', 'logs'),
|
||||||
pklRoot: process.env.FACEAI_PKL_ROOT || '/data/pkl',
|
pklRoot: process.env.FACEAI_PKL_ROOT || '/data/pkl',
|
||||||
fallbackPklRoot: process.env.FACEAI_TEST_PKL_ROOT || '/data/pkl/test',
|
|
||||||
matcherBinary: process.env.FACEAI_MATCHER_BINARY || '/opt/face-recognition/face_matcher',
|
matcherBinary: process.env.FACEAI_MATCHER_BINARY || '/opt/face-recognition/face_matcher',
|
||||||
searchTtlSeconds: Number(process.env.FACEAI_SEARCH_TTL_SECONDS || 24 * 60 * 60),
|
searchTtlSeconds: Number(process.env.FACEAI_SEARCH_TTL_SECONDS || 24 * 60 * 60),
|
||||||
resultTtlSeconds: Number(process.env.FACEAI_RESULT_TTL_SECONDS || 24 * 60 * 60)
|
resultTtlSeconds: Number(process.env.FACEAI_RESULT_TTL_SECONDS || 24 * 60 * 60)
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,22 @@
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
|
import { resolveRacePklAvailability } from '../../backend/src/race-storage.js';
|
||||||
|
|
||||||
async function fileExists(filePath) {
|
export async function resolvePklPath({ raceId, raceStorage, pklRoot }) {
|
||||||
try {
|
const availability = await resolveRacePklAvailability({
|
||||||
await fs.access(filePath);
|
pklRoot,
|
||||||
return true;
|
race: {
|
||||||
} catch {
|
id: raceId,
|
||||||
return false;
|
storage: raceStorage
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
export async function resolvePklPath({ raceId, pklRoot, fallbackPklRoot }) {
|
if (!availability.available || !availability.pklPath) {
|
||||||
const preferred = path.join(pklRoot, String(raceId), 'face_encodings.pkl');
|
throw new Error(availability.message || `No PKL file available for race ${raceId}`);
|
||||||
if (await fileExists(preferred)) {
|
|
||||||
return preferred;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const flatFile = path.join(pklRoot, `${raceId}.pkl`);
|
return availability.pklPath;
|
||||||
if (await fileExists(flatFile)) {
|
|
||||||
return flatFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackEntries = await fs.readdir(fallbackPklRoot).catch(() => []);
|
|
||||||
const fallbackFile = fallbackEntries.find((entry) => entry.toLowerCase().endsWith('.pkl'));
|
|
||||||
if (fallbackFile) {
|
|
||||||
return path.join(fallbackPklRoot, fallbackFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`No PKL file available for race ${raceId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runFaceMatcher({ matcherBinary, selfiePath, pklPath, csvPath, logPath, timeoutMs }) {
|
export async function runFaceMatcher({ matcherBinary, selfiePath, pklPath, csvPath, logPath, timeoutMs }) {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,55 @@ import { parseMatcherCsv, resolvePklPath, runFaceMatcher } from './worker-utils.
|
||||||
|
|
||||||
const connection = createRedisConnection(config.redisUrl);
|
const connection = createRedisConnection(config.redisUrl);
|
||||||
|
|
||||||
|
function formatLogLine(message, details) {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
if (details === undefined) {
|
||||||
|
return `[${timestamp}] ${message}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `[${timestamp}] ${message} ${JSON.stringify(details)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendSearchLog(logPath, message, details) {
|
||||||
|
await fs.mkdir(path.dirname(logPath), { recursive: true });
|
||||||
|
await fs.appendFile(logPath, formatLogLine(message, details), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveCompletionCode(logPath, matchCount) {
|
||||||
|
if (matchCount > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matcherLog = await fs.readFile(logPath, 'utf8').catch(() => '');
|
||||||
|
if (/nessun\s+volt|no\s+faces?|no\s+face|0\s+faces?/i.test(matcherLog)) {
|
||||||
|
return 'NO_FACES_FOUND';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'NO_FACES_FOUND';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeSearch(search, searchId, searchLogPath, matchCount, matches, completionCode) {
|
||||||
|
const result = await storeResultRecord(connection, {
|
||||||
|
raceId: search.raceId,
|
||||||
|
raceName: search.raceName,
|
||||||
|
userId: search.userId,
|
||||||
|
returnUrl: search.returnUrl,
|
||||||
|
lang: search.lang,
|
||||||
|
matches
|
||||||
|
}, config.resultTtlSeconds);
|
||||||
|
|
||||||
|
await appendSearchLog(searchLogPath, 'Completed FaceAI search', {
|
||||||
|
resultId: result.id,
|
||||||
|
matchCount,
|
||||||
|
completionCode
|
||||||
|
});
|
||||||
|
|
||||||
|
await markSearchCompleted(connection, searchId, result.id, matchCount, config.searchTtlSeconds, {
|
||||||
|
completionCode
|
||||||
|
});
|
||||||
|
await releaseActiveSearchLock(connection, search.userId, searchId);
|
||||||
|
}
|
||||||
|
|
||||||
async function processJob(job) {
|
async function processJob(job) {
|
||||||
const searchId = String(job.data.searchId || '');
|
const searchId = String(job.data.searchId || '');
|
||||||
const search = await getSearchRecord(connection, searchId);
|
const search = await getSearchRecord(connection, searchId);
|
||||||
|
|
@ -25,40 +74,73 @@ async function processJob(job) {
|
||||||
await markSearchProcessing(connection, searchId, config.searchTtlSeconds);
|
await markSearchProcessing(connection, searchId, config.searchTtlSeconds);
|
||||||
|
|
||||||
const searchDir = path.join(config.runtimeRoot, 'searches', searchId);
|
const searchDir = path.join(config.runtimeRoot, 'searches', searchId);
|
||||||
|
const searchLogDir = path.join(config.logRoot, 'searches', searchId);
|
||||||
|
const searchLogPath = path.join(searchLogDir, 'worker.log');
|
||||||
await fs.mkdir(searchDir, { recursive: true });
|
await fs.mkdir(searchDir, { recursive: true });
|
||||||
|
await fs.mkdir(searchLogDir, { recursive: true });
|
||||||
|
|
||||||
|
await appendSearchLog(searchLogPath, 'Starting FaceAI search', {
|
||||||
|
searchId,
|
||||||
|
raceId: search.raceId,
|
||||||
|
userId: search.userId,
|
||||||
|
selfiePath: search.selfiePath,
|
||||||
|
runtimeRoot: config.runtimeRoot,
|
||||||
|
logRoot: config.logRoot,
|
||||||
|
queueName: config.queueName
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pklPath = await resolvePklPath({
|
const pklPath = await resolvePklPath({
|
||||||
raceId: search.raceId,
|
raceId: search.raceId,
|
||||||
pklRoot: config.pklRoot,
|
raceStorage: search.raceStorage,
|
||||||
fallbackPklRoot: config.fallbackPklRoot
|
pklRoot: config.pklRoot
|
||||||
|
});
|
||||||
|
|
||||||
|
await appendSearchLog(searchLogPath, 'Resolved PKL path', {
|
||||||
|
pklPath,
|
||||||
|
raceStorage: search.raceStorage
|
||||||
});
|
});
|
||||||
|
|
||||||
const csvPath = path.join(searchDir, 'result.csv');
|
const csvPath = path.join(searchDir, 'result.csv');
|
||||||
const logPath = path.join(searchDir, 'matcher.log');
|
const logPath = path.join(searchLogDir, 'matcher.log');
|
||||||
|
|
||||||
await runFaceMatcher({
|
await appendSearchLog(searchLogPath, 'Running matcher', {
|
||||||
matcherBinary: config.matcherBinary,
|
matcherBinary: config.matcherBinary,
|
||||||
selfiePath: search.selfiePath,
|
|
||||||
pklPath,
|
|
||||||
csvPath,
|
csvPath,
|
||||||
logPath,
|
matcherLogPath: logPath,
|
||||||
timeoutMs: config.workerTimeoutMs
|
timeoutMs: config.workerTimeoutMs
|
||||||
});
|
});
|
||||||
|
|
||||||
const matches = await parseMatcherCsv(csvPath);
|
try {
|
||||||
const result = await storeResultRecord(connection, {
|
await runFaceMatcher({
|
||||||
raceId: search.raceId,
|
matcherBinary: config.matcherBinary,
|
||||||
raceName: search.raceName,
|
selfiePath: search.selfiePath,
|
||||||
userId: search.userId,
|
pklPath,
|
||||||
returnUrl: search.returnUrl,
|
csvPath,
|
||||||
lang: search.lang,
|
logPath,
|
||||||
matches
|
timeoutMs: config.workerTimeoutMs
|
||||||
}, config.resultTtlSeconds);
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message === 'face_matcher exited with code 1') {
|
||||||
|
await appendSearchLog(searchLogPath, 'Matcher reported no detectable faces', {
|
||||||
|
matcherLogPath: logPath,
|
||||||
|
selfiePath: search.selfiePath
|
||||||
|
});
|
||||||
|
await completeSearch(search, searchId, searchLogPath, 0, [], 'NO_FACES_FOUND');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await markSearchCompleted(connection, searchId, result.id, matches.length, config.searchTtlSeconds);
|
throw error;
|
||||||
await releaseActiveSearchLock(connection, search.userId, searchId);
|
}
|
||||||
|
|
||||||
|
const matches = await parseMatcherCsv(csvPath);
|
||||||
|
const completionCode = await resolveCompletionCode(logPath, matches.length);
|
||||||
|
await completeSearch(search, searchId, searchLogPath, matches.length, matches, completionCode);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await appendSearchLog(searchLogPath, 'FaceAI search failed', {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack || null
|
||||||
|
});
|
||||||
await markSearchFailed(connection, searchId, 'PROCESSOR_ERROR', error.message, config.searchTtlSeconds);
|
await markSearchFailed(connection, searchId, 'PROCESSOR_ERROR', error.message, config.searchTtlSeconds);
|
||||||
await releaseActiveSearchLock(connection, search.userId, searchId);
|
await releaseActiveSearchLock(connection, search.userId, searchId);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -76,7 +158,7 @@ worker.on('completed', (job) => {
|
||||||
|
|
||||||
worker.on('failed', (job, error) => {
|
worker.on('failed', (job, error) => {
|
||||||
const searchId = job?.data?.searchId || 'unknown';
|
const searchId = job?.data?.searchId || 'unknown';
|
||||||
console.error(`Failed FaceAI search ${searchId}: ${error.message}`);
|
console.error(`Failed FaceAI search ${searchId}:`, error);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`FaceAI processor listening on queue ${config.queueName} with concurrency ${config.workerConcurrency}`);
|
console.log(`FaceAI processor listening on queue ${config.queueName} with concurrency ${config.workerConcurrency}`);
|
||||||
|
|
@ -3,12 +3,13 @@ services:
|
||||||
image: node:20-alpine
|
image: node:20-alpine
|
||||||
container_name: regalami-faceai
|
container_name: regalami-faceai
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
command: sh -c "npm run start --workspace @regalami/faceai-backend"
|
command: sh -c "mkdir -p /data/logs && npm run start --workspace @regalami/faceai-backend >> /data/logs/backend.log 2>&1"
|
||||||
environment:
|
environment:
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
FACEAI_FRONTEND_URL: http://localhost:3001
|
FACEAI_FRONTEND_URL: http://localhost:3001
|
||||||
FACEAI_PUBLIC_BASE_URL: http://localhost:3001
|
FACEAI_PUBLIC_BASE_URL: http://localhost:3001
|
||||||
FACEAI_LEGACY_RETURN_URL: http://localhost:8080/faceai_return.php
|
FACEAI_LEGACY_RETURN_URL: http://localhost:8080/faceai_return.php
|
||||||
|
FACEAI_PKL_ROOT: /data/pkl
|
||||||
FACEAI_ENABLE_LOCAL_LEGACY_STATIC: 1
|
FACEAI_ENABLE_LOCAL_LEGACY_STATIC: 1
|
||||||
FACEAI_LOCAL_LEGACY_STATIC_ROOT: /legacy-www
|
FACEAI_LOCAL_LEGACY_STATIC_ROOT: /legacy-www
|
||||||
FACEAI_SHARED_SECRET: change-me
|
FACEAI_SHARED_SECRET: change-me
|
||||||
|
|
@ -16,9 +17,12 @@ services:
|
||||||
FACEAI_REDIS_URL: redis://redis:6379
|
FACEAI_REDIS_URL: redis://redis:6379
|
||||||
FACEAI_RUNTIME_ROOT: /data/runtime
|
FACEAI_RUNTIME_ROOT: /data/runtime
|
||||||
FACEAI_UPLOAD_ROOT: /data/runtime/uploads
|
FACEAI_UPLOAD_ROOT: /data/runtime/uploads
|
||||||
|
FACEAI_LOG_ROOT: /data/logs
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
- ./logs:/data/logs
|
||||||
- ../www:/legacy-www:ro
|
- ../www:/legacy-www:ro
|
||||||
|
- ../test_pkl:/data/pkl:ro
|
||||||
- faceai-runtime:/data/runtime
|
- faceai-runtime:/data/runtime
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
|
|
@ -26,22 +30,26 @@ services:
|
||||||
- redis
|
- redis
|
||||||
|
|
||||||
processor:
|
processor:
|
||||||
image: node:20-bookworm-slim
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/processor.Dockerfile
|
||||||
|
image: regalami-faceai-processor-local
|
||||||
container_name: regalami-faceai-processor
|
container_name: regalami-faceai-processor
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
command: sh -c "npm run start --workspace @regalami/faceai-processor"
|
command: sh -c "mkdir -p /data/logs && npm run start --workspace @regalami/faceai-processor >> /data/logs/processor.log 2>&1"
|
||||||
environment:
|
environment:
|
||||||
FACEAI_REDIS_URL: redis://redis:6379
|
FACEAI_REDIS_URL: redis://redis:6379
|
||||||
FACEAI_QUEUE_NAME: faceai-searches
|
FACEAI_QUEUE_NAME: faceai-searches
|
||||||
FACEAI_RUNTIME_ROOT: /data/runtime
|
FACEAI_RUNTIME_ROOT: /data/runtime
|
||||||
|
FACEAI_LOG_ROOT: /data/logs
|
||||||
FACEAI_PKL_ROOT: /data/pkl
|
FACEAI_PKL_ROOT: /data/pkl
|
||||||
FACEAI_TEST_PKL_ROOT: /data/pkl/test
|
|
||||||
FACEAI_WORKER_CONCURRENCY: 2
|
FACEAI_WORKER_CONCURRENCY: 2
|
||||||
FACEAI_MATCHER_BINARY: /opt/face-recognition/face_matcher
|
FACEAI_MATCHER_BINARY: /opt/face-recognition/face_matcher
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
- ./logs:/data/logs
|
||||||
- ../bin/Face_Recognition_Unix:/opt/face-recognition:ro
|
- ../bin/Face_Recognition_Unix:/opt/face-recognition:ro
|
||||||
- ../test_pkl:/data/pkl/test:ro
|
- ../test_pkl:/data/pkl:ro
|
||||||
- faceai-runtime:/data/runtime
|
- faceai-runtime:/data/runtime
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
|
|
@ -55,6 +63,7 @@ services:
|
||||||
image: php:8.3-apache
|
image: php:8.3-apache
|
||||||
container_name: regalami-legacy-php
|
container_name: regalami-legacy-php
|
||||||
environment:
|
environment:
|
||||||
|
FACEAI_FEATURE_ENABLED: 1
|
||||||
FACEAI_BACKEND_INTERNAL_URL: http://faceai:3001
|
FACEAI_BACKEND_INTERNAL_URL: http://faceai:3001
|
||||||
FACEAI_FRONTEND_URL: http://localhost:3001
|
FACEAI_FRONTEND_URL: http://localhost:3001
|
||||||
FACEAI_SHARED_SECRET: change-me
|
FACEAI_SHARED_SECRET: change-me
|
||||||
|
|
|
||||||
8
faceai/docker/processor.Dockerfile
Normal file
8
faceai/docker/processor.Dockerfile
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
FROM node:22-trixie-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get upgrade -y \
|
||||||
|
&& apt-get install -y --no-install-recommends libxcb1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
@ -10,7 +10,7 @@ Add an internal processor service that executes `face_matcher` jobs for the publ
|
||||||
- add a dedicated `processor` workspace and container scaffold
|
- add a dedicated `processor` workspace and container scaffold
|
||||||
- replace in-memory search orchestration in the public backend
|
- replace in-memory search orchestration in the public backend
|
||||||
- preserve the existing frontend polling and legacy return flow
|
- preserve the existing frontend polling and legacy return flow
|
||||||
- support local PKL testing from `test_pkl/`
|
- support local PKL testing from `test_pkl/` mounted with the same directory shape used in hosted deployment
|
||||||
|
|
||||||
This slice does not yet implement production NAS mounting, persistent databases, or a final parser tailored to the real matcher CSV format.
|
This slice does not yet implement production NAS mounting, persistent databases, or a final parser tailored to the real matcher CSV format.
|
||||||
|
|
||||||
|
|
@ -53,25 +53,34 @@ The lock is released only when the processor marks the search as terminal: `comp
|
||||||
|
|
||||||
## Race And PKL Resolution
|
## Race And PKL Resolution
|
||||||
|
|
||||||
The canonical race key is the legacy `id_gara`, already exposed as `raceId` in the existing handoff flow.
|
The canonical race key is still the legacy `id_gara`, but the worker no longer guesses the PKL path from `raceId` alone.
|
||||||
|
|
||||||
The processor resolves the PKL path using a race-based directory layout:
|
The legacy handoff must provide a `raceStorage` object with:
|
||||||
|
|
||||||
|
- `year`
|
||||||
|
- `monthFolder` like `04.APRILE`
|
||||||
|
- `raceFolder` like `PISA`
|
||||||
|
|
||||||
|
The processor resolves the PKL path using this mounted directory layout:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/data/pkl/
|
/data/pkl/
|
||||||
101/
|
2026/
|
||||||
face_encodings.pkl
|
04.APRILE/
|
||||||
202/
|
PISA/
|
||||||
face_encodings.pkl
|
face_encodings_20260330_170210.pkl
|
||||||
|
LUCCA/
|
||||||
|
face_encodings_20260330_170155.pkl
|
||||||
```
|
```
|
||||||
|
|
||||||
The lookup rule is:
|
The lookup rule is:
|
||||||
|
|
||||||
1. try `/data/pkl/{raceId}/face_encodings.pkl`
|
1. resolve `/data/pkl/{year}/{monthFolder}/{raceFolder}`
|
||||||
2. optionally fall back to `/data/pkl/{raceId}.pkl`
|
2. list files at that race root
|
||||||
3. fail the job if neither exists
|
3. take the first `.pkl` file found there, regardless of filename
|
||||||
|
4. fail the job if the directory does not exist or contains no `.pkl` file
|
||||||
|
|
||||||
For local development, `test_pkl/` is mounted into `/data/pkl/test` and the backend can fall back to the first `.pkl` file in that folder when no race-specific file exists yet.
|
For local development, `test_pkl/` is mounted directly into `/data/pkl` in both the public FaceAI container and the processor container, so the same rule is used in every environment.
|
||||||
|
|
||||||
## Shared Runtime Storage
|
## Shared Runtime Storage
|
||||||
|
|
||||||
|
|
@ -91,14 +100,15 @@ Both the public backend and the processor mount the same writable runtime direct
|
||||||
|
|
||||||
1. frontend uploads a selfie and calls `POST /api/searches`
|
1. frontend uploads a selfie and calls `POST /api/searches`
|
||||||
2. backend validates session, rate limit, and active-user lock
|
2. backend validates session, rate limit, and active-user lock
|
||||||
3. backend stores the upload and creates a Redis search record with status `queued`
|
3. backend verifies that the mounted race directory exists and already contains a `.pkl`; if not, it rejects the request before queueing
|
||||||
4. backend enqueues a BullMQ job
|
4. backend stores the upload and creates a Redis search record with status `queued`
|
||||||
5. processor picks up the job and sets status `processing`
|
5. backend enqueues a BullMQ job
|
||||||
6. processor runs `face_matcher`
|
6. processor picks up the job and sets status `processing`
|
||||||
7. processor parses CSV output into matches
|
7. processor runs `face_matcher`
|
||||||
8. processor stores a result record and marks the search `completed`
|
8. processor parses CSV output into matches
|
||||||
9. frontend polling reads Redis-backed state through `GET /api/searches/:id`
|
9. processor stores a result record and marks the search `completed`
|
||||||
10. existing redirect flow sends the user back to the legacy filtered page
|
10. frontend polling reads Redis-backed state through `GET /api/searches/:id`
|
||||||
|
11. existing redirect flow sends the user back to the legacy filtered page
|
||||||
|
|
||||||
## Search Record Shape
|
## Search Record Shape
|
||||||
|
|
||||||
|
|
@ -107,6 +117,11 @@ Both the public backend and the processor mount the same writable runtime direct
|
||||||
"id": "search_...",
|
"id": "search_...",
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"raceId": "101",
|
"raceId": "101",
|
||||||
|
"raceStorage": {
|
||||||
|
"year": "2026",
|
||||||
|
"monthFolder": "04.APRILE",
|
||||||
|
"raceFolder": "PISA"
|
||||||
|
},
|
||||||
"userId": "legacy-user-1",
|
"userId": "legacy-user-1",
|
||||||
"returnUrl": "https://...",
|
"returnUrl": "https://...",
|
||||||
"lang": "it",
|
"lang": "it",
|
||||||
|
|
@ -162,5 +177,4 @@ Both the public backend and the processor mount the same writable runtime direct
|
||||||
|
|
||||||
- confirm the real CSV columns emitted by `face_matcher`
|
- confirm the real CSV columns emitted by `face_matcher`
|
||||||
- verify the Linux binary shared library requirements inside the processor image
|
- verify the Linux binary shared library requirements inside the processor image
|
||||||
- replace the PKL fallback with a strict NAS-backed race mapping once the final folder layout is agreed
|
|
||||||
- add cleanup jobs for expired runtime files
|
- add cleanup jobs for expired runtime files
|
||||||
64
faceai/package-lock.json
generated
64
faceai/package-lock.json
generated
|
|
@ -11,6 +11,7 @@
|
||||||
"apps/processor"
|
"apps/processor"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -621,6 +622,22 @@
|
||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@regalami/faceai-backend": {
|
"node_modules/@regalami/faceai-backend": {
|
||||||
"resolved": "apps/backend",
|
"resolved": "apps/backend",
|
||||||
"link": true
|
"link": true
|
||||||
|
|
@ -2242,6 +2259,53 @@
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.8",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,13 @@
|
||||||
"dev:processor": "npm run dev --workspace @regalami/faceai-processor",
|
"dev:processor": "npm run dev --workspace @regalami/faceai-processor",
|
||||||
"build": "npm run build --workspace @regalami/faceai-frontend && npm run build --workspace @regalami/faceai-backend",
|
"build": "npm run build --workspace @regalami/faceai-frontend && npm run build --workspace @regalami/faceai-backend",
|
||||||
"start": "npm run start --workspace @regalami/faceai-backend",
|
"start": "npm run start --workspace @regalami/faceai-backend",
|
||||||
"start:processor": "npm run start --workspace @regalami/faceai-processor"
|
"start:processor": "npm run start --workspace @regalami/faceai-processor",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:headed": "playwright test --headed",
|
||||||
|
"test:e2e:install": "playwright install chromium"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
faceai/playwright.config.js
Normal file
21
faceai/playwright.config.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
const { defineConfig } = require('@playwright/test');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
timeout: 10 * 60 * 1000,
|
||||||
|
expect: {
|
||||||
|
timeout: 30 * 1000
|
||||||
|
},
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
reporter: [['list'], ['html', { open: 'never' }]],
|
||||||
|
globalSetup: require.resolve('./tests/e2e/global-setup.js'),
|
||||||
|
globalTeardown: require.resolve('./tests/e2e/global-teardown.js'),
|
||||||
|
use: {
|
||||||
|
browserName: 'chromium',
|
||||||
|
headless: true,
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
video: 'retain-on-failure'
|
||||||
|
}
|
||||||
|
});
|
||||||
399
faceai/tests/e2e/faceai-simulator.spec.js
Normal file
399
faceai/tests/e2e/faceai-simulator.spec.js
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const {
|
||||||
|
EXPECTED_MATCH_COUNT,
|
||||||
|
FACEAI_BASE_URL,
|
||||||
|
buildHandoffUrl,
|
||||||
|
buildSimulatorUrl,
|
||||||
|
getSearchArtifacts,
|
||||||
|
getSelfiePath,
|
||||||
|
readUtf8
|
||||||
|
} = require('./faceai-test-utils');
|
||||||
|
|
||||||
|
const FACEAI_HOME_URL_RE = /http:\/\/(localhost|127\.0\.0\.1):3001\/?(?:\?.*)?$/;
|
||||||
|
const FACEAI_CALLBACK_URL_RE = /http:\/\/(localhost|127\.0\.0\.1):3001\/auth\/callback\?token=/;
|
||||||
|
const FACEAI_RETURN_URL_RE = /http:\/\/(localhost|127\.0\.0\.1):8080\/faceai_return\.php\?resultId=.*token=.*/;
|
||||||
|
const LEGACY_HOME_URL_RE = /http:\/\/(localhost|127\.0\.0\.1):8080\/index\.jsp$/;
|
||||||
|
|
||||||
|
function buildLegacySimulatorReturnMatcher(raceId) {
|
||||||
|
return new RegExp(`http:\\/\\/(localhost|127\\.0\\.0\\.1):8080\\/faceai_simulator\\.php\\?raceId=${raceId}.*`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertLogDoesNotContain(content, patterns, label) {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
expect(content, `${label} should not contain ${pattern}`).not.toMatch(pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForFaceAiHome(page) {
|
||||||
|
await page.waitForURL((url) => FACEAI_CALLBACK_URL_RE.test(url.toString()) || FACEAI_HOME_URL_RE.test(url.toString()), {
|
||||||
|
timeout: 60 * 1000
|
||||||
|
});
|
||||||
|
await expect(page.getByRole('heading', { name: 'Trova le tue foto con un selfie' })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launchFromSimulator(page, options = {}) {
|
||||||
|
const simulatorUrl = buildSimulatorUrl(options);
|
||||||
|
await page.goto(simulatorUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page.locator('#faceaiLaunchButton')).toBeVisible();
|
||||||
|
await expect(page.locator('select#tipoPuntoFoto')).toHaveCount(0);
|
||||||
|
await page.locator('#faceaiLaunchButton').click();
|
||||||
|
await waitForFaceAiHome(page);
|
||||||
|
return simulatorUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterViaHandoff(page, options = {}) {
|
||||||
|
await page.goto(buildHandoffUrl(options), { waitUntil: 'domcontentloaded' });
|
||||||
|
await waitForFaceAiHome(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSearch(page, selfieName) {
|
||||||
|
const createResponsePromise = page.waitForResponse((response) => {
|
||||||
|
return response.url().includes('/api/searches')
|
||||||
|
&& response.request().method() === 'POST'
|
||||||
|
&& response.status() === 201;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('input[type="file"]').setInputFiles(getSelfiePath(selfieName));
|
||||||
|
await expect(page.getByText(selfieName)).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Avvia ricerca Face ID' }).click();
|
||||||
|
|
||||||
|
const createResponse = await createResponsePromise;
|
||||||
|
return createResponse.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSearchStatus(page, searchId) {
|
||||||
|
return page.evaluate(async ({ searchId }) => {
|
||||||
|
const response = await fetch(`/api/searches/${searchId}`, { credentials: 'include' });
|
||||||
|
const body = await response.json().catch(() => null);
|
||||||
|
return {
|
||||||
|
statusCode: response.status,
|
||||||
|
body
|
||||||
|
};
|
||||||
|
}, { searchId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSearchCondition(page, searchId, predicate, timeoutMs = 30 * 1000) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
let lastPayload = null;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const payload = await fetchSearchStatus(page, searchId);
|
||||||
|
lastPayload = payload;
|
||||||
|
if (payload.statusCode === 200 && predicate(payload.body)) {
|
||||||
|
return payload.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Timed out waiting for search ${searchId}. Last payload: ${JSON.stringify(lastPayload)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForLegacyResult(page, expectedMatchCount = null) {
|
||||||
|
await page.waitForURL(FACEAI_RETURN_URL_RE, {
|
||||||
|
timeout: 6 * 60 * 1000
|
||||||
|
});
|
||||||
|
await expect(page.locator('.sim-banner')).toContainText('Vista filtrata da FaceAI');
|
||||||
|
if (expectedMatchCount === null) {
|
||||||
|
await expect(page.locator('.gallery-card').first()).toBeVisible();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator('.sim-banner')).toContainText(String(expectedMatchCount));
|
||||||
|
await expect(page.locator('.gallery-card')).toHaveCount(expectedMatchCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifySearchLogs(searchId, { expectedMatchCount, expectedSelfieName }) {
|
||||||
|
const artifacts = getSearchArtifacts(searchId);
|
||||||
|
const [backendLog, processorLog, workerLog, matcherLog] = await Promise.all([
|
||||||
|
readUtf8(artifacts.backendLogPath),
|
||||||
|
readUtf8(artifacts.processorLogPath),
|
||||||
|
readUtf8(artifacts.workerLogPath),
|
||||||
|
readUtf8(artifacts.matcherLogPath)
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(workerLog).toContain('Completed FaceAI search');
|
||||||
|
if (expectedMatchCount !== undefined) {
|
||||||
|
expect(workerLog).toContain(`"matchCount":${expectedMatchCount}`);
|
||||||
|
}
|
||||||
|
if (expectedSelfieName) {
|
||||||
|
expect(matcherLog).toContain(expectedSelfieName);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertLogDoesNotContain(backendLog, [/\bnpm error\b/i, /\berror:\b/i, /\bfailed\b/i], 'backend.log');
|
||||||
|
assertLogDoesNotContain(processorLog, [new RegExp(`Failed FaceAI search ${searchId}`, 'i'), /\bnpm error\b/i], 'processor.log');
|
||||||
|
assertLogDoesNotContain(workerLog, [/FaceAI search failed/i], 'worker.log');
|
||||||
|
assertLogDoesNotContain(matcherLog, [/\[ERROR\]/i, /Traceback/i], 'matcher.log');
|
||||||
|
|
||||||
|
return { backendLog, processorLog, workerLog, matcherLog };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeContexts(contexts) {
|
||||||
|
await Promise.all(contexts.map((context) => context.close()));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('runs the simulator flow through FaceAI and returns to the filtered legacy result', async ({ page }) => {
|
||||||
|
await launchFromSimulator(page, {
|
||||||
|
raceId: '202',
|
||||||
|
raceSlug: 'mezza-di-pisa',
|
||||||
|
raceName: 'Mezza di Pisa',
|
||||||
|
raceFolder: 'PISA'
|
||||||
|
});
|
||||||
|
|
||||||
|
const search = await startSearch(page, 'DSC_1960.JPG');
|
||||||
|
|
||||||
|
await waitForLegacyResult(page, EXPECTED_MATCH_COUNT);
|
||||||
|
await expect(page.locator('.gallery-card').filter({ hasText: 'DSC_1960.JPG' }).first()).toBeVisible();
|
||||||
|
|
||||||
|
await verifySearchLogs(search.id, {
|
||||||
|
expectedMatchCount: EXPECTED_MATCH_COUNT,
|
||||||
|
expectedSelfieName: 'DSC_1960.JPG'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows the unsupported-race message when the current race has no PKL data and lets the user go back', async ({ page }) => {
|
||||||
|
await launchFromSimulator(page, {
|
||||||
|
raceId: '404',
|
||||||
|
raceSlug: 'corsa-di-livorno',
|
||||||
|
raceName: 'Corsa di Livorno',
|
||||||
|
raceFolder: 'LIVORNO'
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.locator('.faceai-feedback')).toContainText('FaceAI non è disponibile per questa gara.');
|
||||||
|
await expect(page.locator('input[type="file"]')).toBeDisabled();
|
||||||
|
await expect(page.getByRole('button', { name: 'Scegli immagine' })).toBeDisabled();
|
||||||
|
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await expect(page).toHaveURL(FACEAI_HOME_URL_RE);
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Torna alla pagina gara' }).click();
|
||||||
|
await expect(page).toHaveURL(buildLegacySimulatorReturnMatcher('404'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows a localized invalid-race error when session race data points to a missing folder', async ({ page }) => {
|
||||||
|
const consoleErrors = [];
|
||||||
|
page.on('console', (message) => {
|
||||||
|
if (message.type() === 'error') {
|
||||||
|
consoleErrors.push(message.text());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const simulatorUrl = buildSimulatorUrl({
|
||||||
|
raceId: '405',
|
||||||
|
lang: 'en',
|
||||||
|
raceSlug: 'ghost-race',
|
||||||
|
raceName: 'Ghost Race',
|
||||||
|
raceFolder: 'THIS RACE DOES NOT EXIST'
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(simulatorUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page.locator('#faceaiLaunchButton')).toBeVisible();
|
||||||
|
await page.locator('#faceaiLaunchButton').click();
|
||||||
|
|
||||||
|
await page.waitForURL(FACEAI_HOME_URL_RE, {
|
||||||
|
timeout: 60 * 1000
|
||||||
|
});
|
||||||
|
await expect(page.getByRole('heading', { name: 'Find your photos with a selfie' })).toBeVisible();
|
||||||
|
await expect(page.locator('.faceai-feedback')).toContainText('The race data received for this session is invalid. Go back to the race page and reopen Face ID from the correct race.');
|
||||||
|
await expect(page.locator('input[type="file"]')).toBeDisabled();
|
||||||
|
await expect(page.getByRole('button', { name: 'Choose image' })).toBeDisabled();
|
||||||
|
await expect(page.getByRole('button', { name: 'Start Face ID search' })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('link', { name: 'Back to the race page' })).toBeVisible();
|
||||||
|
await expect.poll(() => {
|
||||||
|
return consoleErrors.find((entry) => entry.includes('[FaceAI] Invalid race data:')) || null;
|
||||||
|
}).toContain('RACE_DIRECTORY_NOT_FOUND');
|
||||||
|
await expect.poll(() => {
|
||||||
|
return consoleErrors.find((entry) => entry.includes('[FaceAI] Invalid race data:')) || null;
|
||||||
|
}).toContain('THIS RACE DOES NOT EXIST');
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Back to the race page' }).click();
|
||||||
|
await expect(page).toHaveURL(buildLegacySimulatorReturnMatcher('405'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a not-logged-in user after clicking the Face ID button and sends them back to the original race page', async ({ page }) => {
|
||||||
|
const simulatorUrl = buildSimulatorUrl({
|
||||||
|
raceId: '202',
|
||||||
|
raceSlug: 'mezza-di-pisa',
|
||||||
|
raceName: 'Mezza di Pisa',
|
||||||
|
raceFolder: 'PISA'
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(simulatorUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page.locator('#faceaiLaunchButton')).toBeVisible();
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
if (window.faceAiSimulator) {
|
||||||
|
delete window.faceAiSimulator.devUserId;
|
||||||
|
delete window.faceAiSimulator.devDisplayName;
|
||||||
|
delete window.faceAiSimulator.devEmail;
|
||||||
|
delete window.faceAiSimulator.devMembershipStatus;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('#faceaiLaunchButton').click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(buildLegacySimulatorReturnMatcher('202'));
|
||||||
|
await expect(page).not.toHaveURL(FACEAI_HOME_URL_RE);
|
||||||
|
await expect(page.locator('#faceAiErrorModal')).toBeVisible();
|
||||||
|
await expect(page.locator('#faceAiErrorModalLabel')).toContainText('Face ID non disponibile');
|
||||||
|
await expect(page.locator('#faceAiErrorModalMessage')).toContainText('Il servizio Face ID non e al momento disponibile. Riprova piu tardi.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows the no-face message and allows the user to return to the race page', async ({ page }) => {
|
||||||
|
await launchFromSimulator(page, {
|
||||||
|
raceId: '202',
|
||||||
|
raceSlug: 'mezza-di-pisa',
|
||||||
|
raceName: 'Mezza di Pisa',
|
||||||
|
raceFolder: 'PISA'
|
||||||
|
});
|
||||||
|
|
||||||
|
const search = await startSearch(page, 'DSC_1994.JPG');
|
||||||
|
|
||||||
|
await waitForSearchCondition(page, search.id, (payload) => {
|
||||||
|
return payload.status === 'completed' && payload.completionCode === 'NO_FACES_FOUND';
|
||||||
|
}, 2 * 60 * 1000);
|
||||||
|
|
||||||
|
await expect(page.locator('.faceai-feedback')).toContainText('Nessun volto rilevato nella foto caricata');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await expect(page).toHaveURL(FACEAI_HOME_URL_RE);
|
||||||
|
|
||||||
|
await verifySearchLogs(search.id, {
|
||||||
|
expectedMatchCount: 0,
|
||||||
|
expectedSelfieName: 'DSC_1994.JPG'
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Torna alla pagina gara' }).click();
|
||||||
|
await expect(page).toHaveURL(buildLegacySimulatorReturnMatcher('202'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lets the user retry with a valid photo after a no-face upload and then returns to the filtered legacy result', async ({ page }) => {
|
||||||
|
await launchFromSimulator(page, {
|
||||||
|
raceId: '202',
|
||||||
|
raceSlug: 'mezza-di-pisa',
|
||||||
|
raceName: 'Mezza di Pisa',
|
||||||
|
raceFolder: 'PISA'
|
||||||
|
});
|
||||||
|
|
||||||
|
const noFaceSearch = await startSearch(page, 'DSC_1994.JPG');
|
||||||
|
await waitForSearchCondition(page, noFaceSearch.id, (payload) => {
|
||||||
|
return payload.status === 'completed' && payload.completionCode === 'NO_FACES_FOUND';
|
||||||
|
}, 2 * 60 * 1000);
|
||||||
|
|
||||||
|
await expect(page.locator('.faceai-feedback')).toContainText('Nessun volto rilevato nella foto caricata');
|
||||||
|
await expect(page.locator('input[type="file"]')).toBeEnabled();
|
||||||
|
|
||||||
|
const retrySearch = await startSearch(page, 'DSC_1960.JPG');
|
||||||
|
await waitForLegacyResult(page, EXPECTED_MATCH_COUNT);
|
||||||
|
|
||||||
|
await verifySearchLogs(noFaceSearch.id, {
|
||||||
|
expectedMatchCount: 0,
|
||||||
|
expectedSelfieName: 'DSC_1994.JPG'
|
||||||
|
});
|
||||||
|
await verifySearchLogs(retrySearch.id, {
|
||||||
|
expectedMatchCount: EXPECTED_MATCH_COUNT,
|
||||||
|
expectedSelfieName: 'DSC_1960.JPG'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redirects direct-entry users without FaceAI session data back to the legacy site', async ({ browser }) => {
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(`${FACEAI_BASE_URL}/`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForURL(LEGACY_HOME_URL_RE, { timeout: 30 * 1000 });
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows two users to process different photos at the same time', async ({ browser }) => {
|
||||||
|
const contexts = [await browser.newContext(), await browser.newContext()];
|
||||||
|
const pages = await Promise.all(contexts.map((context) => context.newPage()));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
enterViaHandoff(pages[0], { userId: 'concurrency-user-1' }),
|
||||||
|
enterViaHandoff(pages[1], { userId: 'concurrency-user-2' })
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [searchOne, searchTwo] = await Promise.all([
|
||||||
|
startSearch(pages[0], 'DSC_1960.JPG'),
|
||||||
|
startSearch(pages[1], 'DSC_1987.JPG')
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
waitForLegacyResult(pages[0]),
|
||||||
|
waitForLegacyResult(pages[1])
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
verifySearchLogs(searchOne.id, { expectedSelfieName: 'DSC_1960.JPG' }),
|
||||||
|
verifySearchLogs(searchTwo.id, { expectedSelfieName: 'DSC_1987.JPG' })
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await closeContexts(contexts);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queues the third user until a worker is free and then completes all three searches normally', async ({ browser }) => {
|
||||||
|
const contexts = [await browser.newContext(), await browser.newContext(), await browser.newContext()];
|
||||||
|
const pages = await Promise.all(contexts.map((context) => context.newPage()));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
enterViaHandoff(pages[0], { userId: 'queue-user-1' }),
|
||||||
|
enterViaHandoff(pages[1], { userId: 'queue-user-2' }),
|
||||||
|
enterViaHandoff(pages[2], { userId: 'queue-user-3' })
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [searchOne, searchTwo, searchThree] = await Promise.all([
|
||||||
|
startSearch(pages[0], 'DSC_1960.JPG'),
|
||||||
|
startSearch(pages[1], 'DSC_1987.JPG'),
|
||||||
|
startSearch(pages[2], 'DSC_2058.JPG')
|
||||||
|
]);
|
||||||
|
|
||||||
|
const searchSessions = [
|
||||||
|
{ page: pages[0], searchId: searchOne.id },
|
||||||
|
{ page: pages[1], searchId: searchTwo.id },
|
||||||
|
{ page: pages[2], searchId: searchThree.id }
|
||||||
|
];
|
||||||
|
|
||||||
|
let queuedSearch = null;
|
||||||
|
const deadline = Date.now() + 30 * 1000;
|
||||||
|
while (Date.now() < deadline && !queuedSearch) {
|
||||||
|
const statuses = await Promise.all(searchSessions.map(async (session) => {
|
||||||
|
const payload = await fetchSearchStatus(session.page, session.searchId);
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
search: payload.body
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
const processingCount = statuses.filter((item) => item.search?.status === 'processing').length;
|
||||||
|
queuedSearch = processingCount >= 2
|
||||||
|
? statuses.find((item) => item.search?.status === 'queued') || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!queuedSearch) {
|
||||||
|
await pages[0].waitForTimeout(250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(queuedSearch, 'one search should remain queued while two worker slots are busy').toBeTruthy();
|
||||||
|
|
||||||
|
await waitForSearchCondition(queuedSearch.page, queuedSearch.searchId, (payload) => {
|
||||||
|
return payload.status === 'processing' || payload.status === 'completed';
|
||||||
|
}, 2 * 60 * 1000);
|
||||||
|
|
||||||
|
await Promise.all(pages.map((page) => waitForLegacyResult(page)));
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
verifySearchLogs(searchOne.id, { expectedSelfieName: 'DSC_1960.JPG' }),
|
||||||
|
verifySearchLogs(searchTwo.id, { expectedSelfieName: 'DSC_1987.JPG' }),
|
||||||
|
verifySearchLogs(searchThree.id, { expectedSelfieName: 'DSC_2058.JPG' })
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await closeContexts(contexts);
|
||||||
|
}
|
||||||
|
});
|
||||||
203
faceai/tests/e2e/faceai-test-utils.js
Normal file
203
faceai/tests/e2e/faceai-test-utils.js
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
const fs = require('node:fs/promises');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
|
||||||
|
const ROOT_DIR = path.resolve(__dirname, '..', '..');
|
||||||
|
const WORKSPACE_ROOT = path.resolve(ROOT_DIR, '..');
|
||||||
|
const LOG_ROOT = path.join(ROOT_DIR, 'logs');
|
||||||
|
const SEARCH_LOG_ROOT = path.join(LOG_ROOT, 'searches');
|
||||||
|
const FACEAI_BASE_URL = process.env.FACEAI_E2E_BASE_URL || 'http://127.0.0.1:3001';
|
||||||
|
const SIMULATOR_URL = process.env.FACEAI_E2E_SIMULATOR_URL || 'http://127.0.0.1:8080/faceai_simulator.php?raceId=202&lang=it';
|
||||||
|
const LEGACY_BASE_URL = process.env.FACEAI_E2E_LEGACY_BASE_URL || 'http://127.0.0.1:8080';
|
||||||
|
const LEGACY_HOME_URL = process.env.FACEAI_E2E_LEGACY_HOME_URL || `${LEGACY_BASE_URL}/index.jsp`;
|
||||||
|
const SELFIE_NAME = process.env.FACEAI_E2E_SELFIE || 'DSC_1960.JPG';
|
||||||
|
const EXPECTED_MATCH_COUNT = Number(process.env.FACEAI_E2E_EXPECTED_MATCH_COUNT || '6');
|
||||||
|
|
||||||
|
function quoteShellArg(value) {
|
||||||
|
if (!/[\s"]/u.test(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `"${value.replace(/"/g, '\\"')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(command, args, options = {}) {
|
||||||
|
const { cwd = ROOT_DIR, allowFailure = false } = options;
|
||||||
|
const executable = process.platform === 'win32' && command === 'npm' ? 'npm.cmd' : command;
|
||||||
|
const useShell = process.platform === 'win32';
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = useShell
|
||||||
|
? spawn([executable, ...args].map(quoteShellArg).join(' '), {
|
||||||
|
cwd,
|
||||||
|
env: process.env,
|
||||||
|
shell: true,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
})
|
||||||
|
: spawn(executable, args, {
|
||||||
|
cwd,
|
||||||
|
env: process.env,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('close', (code) => {
|
||||||
|
const result = { code, stdout, stderr };
|
||||||
|
if (code === 0 || allowFailure) {
|
||||||
|
resolve(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = new Error(`Command failed: ${executable} ${args.join(' ')}`);
|
||||||
|
error.result = result;
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dockerCompose(args, options) {
|
||||||
|
return runCommand('docker', ['compose', ...args], options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareHostState() {
|
||||||
|
await fs.rm(LOG_ROOT, { recursive: true, force: true });
|
||||||
|
await fs.mkdir(LOG_ROOT, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForHttp(url, validate, timeoutMs = 3 * 60 * 1000) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const bodyText = await response.text();
|
||||||
|
let parsedBody = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsedBody = JSON.parse(bodyText);
|
||||||
|
} catch {
|
||||||
|
parsedBody = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validate({ response, bodyText, parsedBody })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastError = new Error(`Readiness check did not pass for ${url}.`);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error(`Timed out waiting for ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelfiePath(fileName = SELFIE_NAME) {
|
||||||
|
return path.join(WORKSPACE_ROOT, 'test_pkl', 'test_images', fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSimulatorUrl({
|
||||||
|
raceId = '202',
|
||||||
|
lang = 'it',
|
||||||
|
raceSlug = 'mezza-di-pisa',
|
||||||
|
raceName = 'Mezza di Pisa',
|
||||||
|
raceYear = '2026',
|
||||||
|
raceMonthFolder = '04.APRILE',
|
||||||
|
raceFolder = 'PISA'
|
||||||
|
} = {}) {
|
||||||
|
const url = new URL('/faceai_simulator.php', LEGACY_BASE_URL);
|
||||||
|
url.searchParams.set('raceId', raceId);
|
||||||
|
url.searchParams.set('lang', lang);
|
||||||
|
url.searchParams.set('raceSlug', raceSlug);
|
||||||
|
url.searchParams.set('raceName', raceName);
|
||||||
|
url.searchParams.set('raceYear', raceYear);
|
||||||
|
url.searchParams.set('raceMonthFolder', raceMonthFolder);
|
||||||
|
url.searchParams.set('raceFolder', raceFolder);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHandoffUrl({
|
||||||
|
raceId = '202',
|
||||||
|
lang = 'it',
|
||||||
|
raceSlug = 'mezza-di-pisa',
|
||||||
|
raceName = 'Mezza di Pisa',
|
||||||
|
raceYear = '2026',
|
||||||
|
raceMonthFolder = '04.APRILE',
|
||||||
|
raceFolder = 'PISA',
|
||||||
|
userId = '1',
|
||||||
|
displayName = `Local Test User ${userId}`,
|
||||||
|
email = `local-test-${userId}@example.invalid`,
|
||||||
|
membershipStatus = 'active',
|
||||||
|
returnUrl = buildSimulatorUrl({ raceId, lang, raceSlug, raceName, raceYear, raceMonthFolder, raceFolder })
|
||||||
|
} = {}) {
|
||||||
|
const url = new URL('/faceai_handoff.php', LEGACY_BASE_URL);
|
||||||
|
url.searchParams.set('raceId', raceId);
|
||||||
|
url.searchParams.set('raceSlug', raceSlug);
|
||||||
|
url.searchParams.set('raceName', raceName);
|
||||||
|
url.searchParams.set('raceYear', raceYear);
|
||||||
|
url.searchParams.set('raceMonthFolder', raceMonthFolder);
|
||||||
|
url.searchParams.set('raceFolder', raceFolder);
|
||||||
|
url.searchParams.set('lang', lang);
|
||||||
|
url.searchParams.set('returnUrl', returnUrl);
|
||||||
|
url.searchParams.set('devUserId', userId);
|
||||||
|
url.searchParams.set('devDisplayName', displayName);
|
||||||
|
url.searchParams.set('devEmail', email);
|
||||||
|
url.searchParams.set('devMembershipStatus', membershipStatus);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSearchArtifacts(searchId) {
|
||||||
|
const searchRoot = path.join(SEARCH_LOG_ROOT, searchId);
|
||||||
|
return {
|
||||||
|
searchRoot,
|
||||||
|
backendLogPath: path.join(LOG_ROOT, 'backend.log'),
|
||||||
|
processorLogPath: path.join(LOG_ROOT, 'processor.log'),
|
||||||
|
workerLogPath: path.join(searchRoot, 'worker.log'),
|
||||||
|
matcherLogPath: path.join(searchRoot, 'matcher.log')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readUtf8(filePath) {
|
||||||
|
return fs.readFile(filePath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ROOT_DIR,
|
||||||
|
LOG_ROOT,
|
||||||
|
SEARCH_LOG_ROOT,
|
||||||
|
FACEAI_BASE_URL,
|
||||||
|
LEGACY_BASE_URL,
|
||||||
|
LEGACY_HOME_URL,
|
||||||
|
SIMULATOR_URL,
|
||||||
|
SELFIE_NAME,
|
||||||
|
EXPECTED_MATCH_COUNT,
|
||||||
|
buildHandoffUrl,
|
||||||
|
buildSimulatorUrl,
|
||||||
|
dockerCompose,
|
||||||
|
getSearchArtifacts,
|
||||||
|
getSelfiePath,
|
||||||
|
prepareHostState,
|
||||||
|
readUtf8,
|
||||||
|
runCommand,
|
||||||
|
waitForHttp
|
||||||
|
};
|
||||||
27
faceai/tests/e2e/global-setup.js
Normal file
27
faceai/tests/e2e/global-setup.js
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
const {
|
||||||
|
FACEAI_BASE_URL,
|
||||||
|
SIMULATOR_URL,
|
||||||
|
dockerCompose,
|
||||||
|
prepareHostState,
|
||||||
|
runCommand,
|
||||||
|
waitForHttp
|
||||||
|
} = require('./faceai-test-utils');
|
||||||
|
|
||||||
|
module.exports = async () => {
|
||||||
|
await prepareHostState();
|
||||||
|
await dockerCompose(['down', '-v', '--remove-orphans'], { allowFailure: true });
|
||||||
|
await runCommand('npm', ['run', 'build']);
|
||||||
|
await dockerCompose(['up', '--build', '-d']);
|
||||||
|
|
||||||
|
await waitForHttp(`${FACEAI_BASE_URL}/health`, ({ response, parsedBody }) => {
|
||||||
|
return response.ok && parsedBody && parsedBody.ok === true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForHttp(`${FACEAI_BASE_URL}/api/health/queue`, ({ response, parsedBody }) => {
|
||||||
|
return response.ok && parsedBody && parsedBody.ok === true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForHttp(SIMULATOR_URL, ({ response, bodyText }) => {
|
||||||
|
return response.ok && bodyText.includes('FaceAI Legacy Simulator');
|
||||||
|
});
|
||||||
|
};
|
||||||
9
faceai/tests/e2e/global-teardown.js
Normal file
9
faceai/tests/e2e/global-teardown.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
const { dockerCompose } = require('./faceai-test-utils');
|
||||||
|
|
||||||
|
module.exports = async () => {
|
||||||
|
if (process.env.FACEAI_E2E_KEEP_STACK === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dockerCompose(['down', '-v', '--remove-orphans'], { allowFailure: true });
|
||||||
|
};
|
||||||
BIN
test_pkl/2026/04.APRILE/PISA/face_encodings_20260412_171808.pkl
Normal file
BIN
test_pkl/2026/04.APRILE/PISA/face_encodings_20260412_171808.pkl
Normal file
Binary file not shown.
Binary file not shown.
BIN
test_pkl/test_images/DSC_1960.JPG
Normal file
BIN
test_pkl/test_images/DSC_1960.JPG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
test_pkl/test_images/DSC_1987.JPG
Normal file
BIN
test_pkl/test_images/DSC_1987.JPG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 612 KiB |
BIN
test_pkl/test_images/DSC_1994.JPG
Normal file
BIN
test_pkl/test_images/DSC_1994.JPG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 459 KiB |
BIN
test_pkl/test_images/DSC_2058.JPG
Normal file
BIN
test_pkl/test_images/DSC_2058.JPG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 647 KiB |
BIN
test_pkl/test_images/DSC_2131.JPG
Normal file
BIN
test_pkl/test_images/DSC_2131.JPG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 634 KiB |
|
|
@ -114,6 +114,19 @@ function getCurrentLangValue() {
|
||||||
return $("html").attr("lang") || "it";
|
return $("html").attr("lang") || "it";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFaceAiStorageValue(fieldId, simulatorKey) {
|
||||||
|
var field = $("#" + fieldId);
|
||||||
|
if (field.length && field.val()) {
|
||||||
|
return field.val();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.faceAiSimulator && window.faceAiSimulator.raceStorage && window.faceAiSimulator.raceStorage[simulatorKey]) {
|
||||||
|
return window.faceAiSimulator.raceStorage[simulatorKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function faceAiFeatureEnabled() {
|
function faceAiFeatureEnabled() {
|
||||||
var config = window.faceAiConfig || {};
|
var config = window.faceAiConfig || {};
|
||||||
var simulatorConfig = window.faceAiSimulator || {};
|
var simulatorConfig = window.faceAiSimulator || {};
|
||||||
|
|
@ -136,6 +149,57 @@ function faceAiEscapeHtml(value) {
|
||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFaceAiDebugEnabled() {
|
||||||
|
var hostname = window.location && window.location.hostname ? window.location.hostname : "";
|
||||||
|
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFaceAiDebugPayload() {
|
||||||
|
var raceYear = getFaceAiStorageValue("faceAiRaceYear", "year");
|
||||||
|
var raceMonthFolder = getFaceAiStorageValue("faceAiRaceMonthFolder", "monthFolder");
|
||||||
|
var raceFolder = getFaceAiStorageValue("faceAiRaceFolder", "raceFolder");
|
||||||
|
var racePathBase = $("#faceAiRacePathBase").val() || "";
|
||||||
|
var raceStorageRelativeDir = $("#faceAiRaceStorageRelativeDir").val() || [raceYear, raceMonthFolder, raceFolder].filter(Boolean).join("/");
|
||||||
|
var simulatorConfig = window.faceAiSimulator || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageUrl: window.location.href,
|
||||||
|
race: {
|
||||||
|
id: $("#id_gara").val() || "",
|
||||||
|
slug: $("#garaDesc").val() || "",
|
||||||
|
name: $("h1.my-4").last().text().replace(/\s+/g, " ").trim(),
|
||||||
|
lang: getCurrentLangValue(),
|
||||||
|
storage: {
|
||||||
|
year: raceYear,
|
||||||
|
monthFolder: raceMonthFolder,
|
||||||
|
raceFolder: raceFolder,
|
||||||
|
pathBase: racePathBase,
|
||||||
|
relativeDir: raceStorageRelativeDir
|
||||||
|
}
|
||||||
|
},
|
||||||
|
simulator: simulatorConfig,
|
||||||
|
handoff: {
|
||||||
|
url: (simulatorConfig && simulatorConfig.handoffUrl) || "faceai_handoff.php",
|
||||||
|
returnUrl: (simulatorConfig && simulatorConfig.returnUrl) || window.location.href
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function logFaceAiDebug(label, extraPayload) {
|
||||||
|
if (!isFaceAiDebugEnabled() || !window.console || typeof window.console.groupCollapsed !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = getFaceAiDebugPayload();
|
||||||
|
if (extraPayload) {
|
||||||
|
payload.extra = extraPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.console.groupCollapsed("[FaceAI] " + label);
|
||||||
|
window.console.log(payload);
|
||||||
|
window.console.groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
function getFaceAiErrorState() {
|
function getFaceAiErrorState() {
|
||||||
if (typeof URLSearchParams === "undefined") {
|
if (typeof URLSearchParams === "undefined") {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -191,6 +255,9 @@ function buildFaceAiLaunchUrl() {
|
||||||
var raceId = $("#id_gara").val() || "";
|
var raceId = $("#id_gara").val() || "";
|
||||||
var raceSlug = $("#garaDesc").val() || "";
|
var raceSlug = $("#garaDesc").val() || "";
|
||||||
var raceName = $("h1.my-4").last().text().replace(/\s+/g, " ").trim();
|
var raceName = $("h1.my-4").last().text().replace(/\s+/g, " ").trim();
|
||||||
|
var raceYear = getFaceAiStorageValue("faceAiRaceYear", "year");
|
||||||
|
var raceMonthFolder = getFaceAiStorageValue("faceAiRaceMonthFolder", "monthFolder");
|
||||||
|
var raceFolder = getFaceAiStorageValue("faceAiRaceFolder", "raceFolder");
|
||||||
var lang = getCurrentLangValue();
|
var lang = getCurrentLangValue();
|
||||||
var handoffUrl = (window.faceAiSimulator && window.faceAiSimulator.handoffUrl) || "faceai_handoff.php";
|
var handoffUrl = (window.faceAiSimulator && window.faceAiSimulator.handoffUrl) || "faceai_handoff.php";
|
||||||
var returnUrl = (window.faceAiSimulator && window.faceAiSimulator.returnUrl) || window.location.href;
|
var returnUrl = (window.faceAiSimulator && window.faceAiSimulator.returnUrl) || window.location.href;
|
||||||
|
|
@ -198,6 +265,9 @@ function buildFaceAiLaunchUrl() {
|
||||||
"raceId=" + encodeURIComponent(raceId),
|
"raceId=" + encodeURIComponent(raceId),
|
||||||
"raceSlug=" + encodeURIComponent(raceSlug),
|
"raceSlug=" + encodeURIComponent(raceSlug),
|
||||||
"raceName=" + encodeURIComponent(raceName),
|
"raceName=" + encodeURIComponent(raceName),
|
||||||
|
"raceYear=" + encodeURIComponent(raceYear),
|
||||||
|
"raceMonthFolder=" + encodeURIComponent(raceMonthFolder),
|
||||||
|
"raceFolder=" + encodeURIComponent(raceFolder),
|
||||||
"lang=" + encodeURIComponent(lang),
|
"lang=" + encodeURIComponent(lang),
|
||||||
"returnUrl=" + encodeURIComponent(returnUrl)
|
"returnUrl=" + encodeURIComponent(returnUrl)
|
||||||
];
|
];
|
||||||
|
|
@ -215,10 +285,18 @@ function buildFaceAiLaunchUrl() {
|
||||||
query.push("devMembershipStatus=" + encodeURIComponent(window.faceAiSimulator.devMembershipStatus));
|
query.push("devMembershipStatus=" + encodeURIComponent(window.faceAiSimulator.devMembershipStatus));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logFaceAiDebug("Legacy launch payload prepared", {
|
||||||
|
query: query.slice(0),
|
||||||
|
raceId: raceId,
|
||||||
|
raceSlug: raceSlug,
|
||||||
|
raceName: raceName
|
||||||
|
});
|
||||||
|
|
||||||
return handoffUrl + "?" + query.join("&");
|
return handoffUrl + "?" + query.join("&");
|
||||||
}
|
}
|
||||||
|
|
||||||
function launchFaceAi() {
|
function launchFaceAi() {
|
||||||
|
logFaceAiDebug("Redirecting to FaceAI handoff");
|
||||||
$("body").addClass("loading");
|
$("body").addClass("loading");
|
||||||
window.location.href = buildFaceAiLaunchUrl();
|
window.location.href = buildFaceAiLaunchUrl();
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -451,6 +529,7 @@ function goPage()
|
||||||
$(function() {
|
$(function() {
|
||||||
initFaceAiRaceSearchButton();
|
initFaceAiRaceSearchButton();
|
||||||
initFaceAiErrorModal();
|
initFaceAiErrorModal();
|
||||||
|
logFaceAiDebug("Legacy race page ready");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ try {
|
||||||
$raceId = faceai_request_value('raceId');
|
$raceId = faceai_request_value('raceId');
|
||||||
$raceSlug = faceai_request_value('raceSlug');
|
$raceSlug = faceai_request_value('raceSlug');
|
||||||
$raceName = faceai_request_value('raceName', $raceSlug !== '' ? $raceSlug : $raceId);
|
$raceName = faceai_request_value('raceName', $raceSlug !== '' ? $raceSlug : $raceId);
|
||||||
|
$raceYear = faceai_request_value('raceYear');
|
||||||
|
$raceMonthFolder = faceai_request_value('raceMonthFolder');
|
||||||
|
$raceFolder = faceai_request_value('raceFolder');
|
||||||
$lang = faceai_request_value('lang', 'it');
|
$lang = faceai_request_value('lang', 'it');
|
||||||
$returnUrl = faceai_request_value('returnUrl');
|
$returnUrl = faceai_request_value('returnUrl');
|
||||||
|
|
||||||
|
|
@ -36,6 +39,20 @@ try {
|
||||||
faceai_redirect_with_error($returnUrl, 'Il tuo account non e abilitato all uso di Face ID.');
|
faceai_redirect_with_error($returnUrl, 'Il tuo account non e abilitato all uso di Face ID.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$racePayload = array(
|
||||||
|
'id' => $raceId,
|
||||||
|
'slug' => $raceSlug !== '' ? $raceSlug : $raceId,
|
||||||
|
'name' => $raceName !== '' ? $raceName : $raceId
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($raceYear !== '' && $raceMonthFolder !== '' && $raceFolder !== '') {
|
||||||
|
$racePayload['storage'] = array(
|
||||||
|
'year' => $raceYear,
|
||||||
|
'monthFolder' => $raceMonthFolder,
|
||||||
|
'raceFolder' => strtoupper(trim($raceFolder))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$payload = array(
|
$payload = array(
|
||||||
'type' => 'handoff',
|
'type' => 'handoff',
|
||||||
'user' => array(
|
'user' => array(
|
||||||
|
|
@ -44,11 +61,7 @@ try {
|
||||||
'email' => $identity['email'],
|
'email' => $identity['email'],
|
||||||
'membershipStatus' => $identity['membershipStatus']
|
'membershipStatus' => $identity['membershipStatus']
|
||||||
),
|
),
|
||||||
'race' => array(
|
'race' => $racePayload,
|
||||||
'id' => $raceId,
|
|
||||||
'slug' => $raceSlug !== '' ? $raceSlug : $raceId,
|
|
||||||
'name' => $raceName !== '' ? $raceName : $raceId
|
|
||||||
),
|
|
||||||
'lang' => $lang,
|
'lang' => $lang,
|
||||||
'returnUrl' => $returnUrl,
|
'returnUrl' => $returnUrl,
|
||||||
'expiresAt' => ((int) round(microtime(true) * 1000)) + (5 * 60 * 1000)
|
'expiresAt' => ((int) round(microtime(true) * 1000)) + (5 * 60 * 1000)
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,19 @@ try {
|
||||||
'token' => $token
|
'token' => $token
|
||||||
));
|
));
|
||||||
$result = faceai_fetch_json($bridgeUrl);
|
$result = faceai_fetch_json($bridgeUrl);
|
||||||
|
$matches = is_array($result['matches'] ?? null) ? $result['matches'] : array();
|
||||||
|
$photos = array_map(static function ($match) {
|
||||||
|
$photoId = (string) ($match['photoId'] ?? ($match['id'] ?? ''));
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'id' => $photoId,
|
||||||
|
'photoId' => $photoId,
|
||||||
|
'label' => (string) ($match['label'] ?? $photoId),
|
||||||
|
'checkpoint' => (string) ($match['checkpoint'] ?? '-'),
|
||||||
|
'previewUrl' => (string) ($match['previewUrl'] ?? ''),
|
||||||
|
'score' => $match['score'] ?? null,
|
||||||
|
);
|
||||||
|
}, $matches);
|
||||||
|
|
||||||
faceai_sim_render_page(array(
|
faceai_sim_render_page(array(
|
||||||
'raceId' => (string) ($result['raceId'] ?? ($payload['raceId'] ?? '')),
|
'raceId' => (string) ($result['raceId'] ?? ($payload['raceId'] ?? '')),
|
||||||
|
|
@ -46,9 +59,9 @@ try {
|
||||||
'raceSlug' => (string) ($result['raceId'] ?? ($payload['raceId'] ?? '')),
|
'raceSlug' => (string) ($result['raceId'] ?? ($payload['raceId'] ?? '')),
|
||||||
'raceName' => (string) ($result['raceName'] ?? ('Race ' . ($payload['raceId'] ?? ''))),
|
'raceName' => (string) ($result['raceName'] ?? ('Race ' . ($payload['raceId'] ?? ''))),
|
||||||
'returnUrl' => (string) ($result['returnUrl'] ?? 'faceai_simulator.php'),
|
'returnUrl' => (string) ($result['returnUrl'] ?? 'faceai_simulator.php'),
|
||||||
'banner' => 'Vista filtrata da FaceAI. Sono state trovate <strong>' . count($result['matches'] ?? array()) . '</strong> foto corrispondenti per l utente corrente.',
|
'banner' => 'Vista filtrata da FaceAI. Sono state trovate <strong>' . count($photos) . '</strong> foto corrispondenti per l utente corrente.',
|
||||||
'totalLabel' => count($result['matches'] ?? array()) . ' foto da FaceAI',
|
'totalLabel' => count($photos) . ' foto da FaceAI',
|
||||||
'photos' => is_array($result['matches'] ?? null) ? $result['matches'] : array(),
|
'photos' => $photos,
|
||||||
'showSimulatorBootstrap' => false
|
'showSimulatorBootstrap' => false
|
||||||
));
|
));
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/faceai_simulator_view.php';
|
require_once __DIR__ . '/faceai_simulator_view.php';
|
||||||
|
|
||||||
$raceId = isset($_GET['raceId']) ? trim((string) $_GET['raceId']) : '101';
|
$raceId = isset($_GET['raceId']) ? trim((string) $_GET['raceId']) : '202';
|
||||||
$lang = isset($_GET['lang']) ? trim((string) $_GET['lang']) : 'it';
|
$lang = isset($_GET['lang']) ? trim((string) $_GET['lang']) : 'it';
|
||||||
$raceSlug = isset($_GET['raceSlug']) ? trim((string) $_GET['raceSlug']) : 'mezza-di-firenze';
|
$raceSlug = isset($_GET['raceSlug']) ? trim((string) $_GET['raceSlug']) : 'mezza-di-pisa';
|
||||||
$raceName = isset($_GET['raceName']) ? trim((string) $_GET['raceName']) : 'Mezza di Firenze';
|
$raceName = isset($_GET['raceName']) ? trim((string) $_GET['raceName']) : 'Mezza di Pisa';
|
||||||
$returnUrl = 'http://localhost:8080/faceai_simulator.php?raceId=' . rawurlencode($raceId) . '&lang=' . rawurlencode($lang) . '&raceSlug=' . rawurlencode($raceSlug) . '&raceName=' . rawurlencode($raceName);
|
$raceYear = isset($_GET['raceYear']) ? trim((string) $_GET['raceYear']) : '2026';
|
||||||
|
$raceMonthFolder = isset($_GET['raceMonthFolder']) ? trim((string) $_GET['raceMonthFolder']) : '04.APRILE';
|
||||||
|
$raceFolder = isset($_GET['raceFolder']) ? trim((string) $_GET['raceFolder']) : 'PISA';
|
||||||
|
$returnUrl = 'http://localhost:8080/faceai_simulator.php?raceId=' . rawurlencode($raceId) . '&lang=' . rawurlencode($lang) . '&raceSlug=' . rawurlencode($raceSlug) . '&raceName=' . rawurlencode($raceName) . '&raceYear=' . rawurlencode($raceYear) . '&raceMonthFolder=' . rawurlencode($raceMonthFolder) . '&raceFolder=' . rawurlencode($raceFolder);
|
||||||
|
|
||||||
$photos = array(
|
$photos = array(
|
||||||
array('id' => 'f101-001', 'thumb' => 'thumb-arrivo-001.jpg', 'label' => 'Arrivo 001', 'checkpoint' => 'Arrivo'),
|
array('id' => 'f101-001', 'thumb' => 'thumb-arrivo-001.jpg', 'label' => 'Arrivo 001', 'checkpoint' => 'Arrivo'),
|
||||||
|
|
@ -27,6 +30,9 @@ faceai_sim_render_page(array(
|
||||||
'lang' => $lang,
|
'lang' => $lang,
|
||||||
'raceSlug' => $raceSlug,
|
'raceSlug' => $raceSlug,
|
||||||
'raceName' => $raceName,
|
'raceName' => $raceName,
|
||||||
|
'raceYear' => $raceYear,
|
||||||
|
'raceMonthFolder' => $raceMonthFolder,
|
||||||
|
'raceFolder' => $raceFolder,
|
||||||
'returnUrl' => $returnUrl,
|
'returnUrl' => $returnUrl,
|
||||||
'banner' => 'Questa pagina PHP simula il punto di ingresso del sito legacy. Il vecchio select con ID <strong>tipoPuntoFoto</strong> viene rimosso dal JavaScript originale e sostituito dal pulsante Face ID.',
|
'banner' => 'Questa pagina PHP simula il punto di ingresso del sito legacy. Il vecchio select con ID <strong>tipoPuntoFoto</strong> viene rimosso dal JavaScript originale e sostituito dal pulsante Face ID.',
|
||||||
'totalLabel' => count($photos) . ' foto demo',
|
'totalLabel' => count($photos) . ' foto demo',
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ function faceai_sim_render_page(array $options)
|
||||||
$lang = $options['lang'];
|
$lang = $options['lang'];
|
||||||
$raceSlug = $options['raceSlug'];
|
$raceSlug = $options['raceSlug'];
|
||||||
$raceName = $options['raceName'];
|
$raceName = $options['raceName'];
|
||||||
|
$raceYear = $options['raceYear'] ?? '';
|
||||||
|
$raceMonthFolder = $options['raceMonthFolder'] ?? '';
|
||||||
|
$raceFolder = $options['raceFolder'] ?? '';
|
||||||
$returnUrl = $options['returnUrl'];
|
$returnUrl = $options['returnUrl'];
|
||||||
$banner = $options['banner'];
|
$banner = $options['banner'];
|
||||||
$totalLabel = $options['totalLabel'];
|
$totalLabel = $options['totalLabel'];
|
||||||
|
|
@ -69,8 +72,8 @@ function faceai_sim_render_page(array $options)
|
||||||
<nav class="navbar fixed-top navbar-expand-lg navbar-light bg-white fixed-top">
|
<nav class="navbar fixed-top navbar-expand-lg navbar-light bg-white fixed-top">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand" href="faceai_simulator.php?raceId=<?php echo faceai_sim_html($raceId); ?>&lang=<?php echo faceai_sim_html($lang); ?>"><img src="images/layout/regalami-un-sorriso-ets-640.png" alt="Regalami Un Sorriso Ets" width="100"></a>
|
<a class="navbar-brand" href="faceai_simulator.php?raceId=<?php echo faceai_sim_html($raceId); ?>&lang=<?php echo faceai_sim_html($lang); ?>"><img src="images/layout/regalami-un-sorriso-ets-640.png" alt="Regalami Un Sorriso Ets" width="100"></a>
|
||||||
<button class="navbar-toggler navbar-toggler-right" type="button"><span class="navbar-toggler-icon"></span></button>
|
<button class="navbar-toggler navbar-toggler-right" type="button" data-toggle="collapse" data-target="#navbarResponsive" aria-controls="navbarResponsive" aria-expanded="false" aria-label="Toggle navigation"><span class="navbar-toggler-icon"></span></button>
|
||||||
<div class="collapse navbar-collapse show" id="navbarResponsive">
|
<div class="collapse navbar-collapse" id="navbarResponsive">
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
<li class="nav-item"><a class="nav-link" href="index.jsp">Home</a></li>
|
<li class="nav-item"><a class="nav-link" href="index.jsp">Home</a></li>
|
||||||
<li class="nav-item"><a class="nav-link" href="associazione.jsp">Associazione</a></li>
|
<li class="nav-item"><a class="nav-link" href="associazione.jsp">Associazione</a></li>
|
||||||
|
|
@ -105,6 +108,9 @@ function faceai_sim_render_page(array $options)
|
||||||
<input name="id_gara" id="id_gara" type="hidden" value="<?php echo faceai_sim_html($raceId); ?>">
|
<input name="id_gara" id="id_gara" type="hidden" value="<?php echo faceai_sim_html($raceId); ?>">
|
||||||
<input name="id_foto" id="id_foto" type="hidden">
|
<input name="id_foto" id="id_foto" type="hidden">
|
||||||
<input name="garaDesc" id="garaDesc" type="hidden" value="<?php echo faceai_sim_html($raceSlug); ?>">
|
<input name="garaDesc" id="garaDesc" type="hidden" value="<?php echo faceai_sim_html($raceSlug); ?>">
|
||||||
|
<input name="faceAiRaceYear" id="faceAiRaceYear" type="hidden" value="<?php echo faceai_sim_html($raceYear); ?>">
|
||||||
|
<input name="faceAiRaceMonthFolder" id="faceAiRaceMonthFolder" type="hidden" value="<?php echo faceai_sim_html($raceMonthFolder); ?>">
|
||||||
|
<input name="faceAiRaceFolder" id="faceAiRaceFolder" type="hidden" value="<?php echo faceai_sim_html($raceFolder); ?>">
|
||||||
<input name="lang" id="lang" type="hidden" value="<?php echo faceai_sim_html($lang); ?>">
|
<input name="lang" id="lang" type="hidden" value="<?php echo faceai_sim_html($lang); ?>">
|
||||||
<input name="pageNumber" id="pageNumber" type="hidden" value="1">
|
<input name="pageNumber" id="pageNumber" type="hidden" value="1">
|
||||||
<input name="actionPage" id="actionPage" type="hidden" value="Foto.abl">
|
<input name="actionPage" id="actionPage" type="hidden" value="Foto.abl">
|
||||||
|
|
@ -147,17 +153,24 @@ function faceai_sim_render_page(array $options)
|
||||||
|
|
||||||
<div class="gallery-grid">
|
<div class="gallery-grid">
|
||||||
<?php foreach ($photos as $photo): ?>
|
<?php foreach ($photos as $photo): ?>
|
||||||
|
<?php
|
||||||
|
$photoId = (string) ($photo['id'] ?? ($photo['photoId'] ?? ''));
|
||||||
|
$photoLabel = (string) ($photo['label'] ?? ($photoId !== '' ? $photoId : ($photo['thumb'] ?? 'Foto senza etichetta')));
|
||||||
|
$photoPreviewUrl = (string) ($photo['previewUrl'] ?? '');
|
||||||
|
$photoThumb = (string) ($photo['thumb'] ?? $photoId);
|
||||||
|
$photoCheckpoint = (string) ($photo['checkpoint'] ?? '-');
|
||||||
|
?>
|
||||||
<div class="gallery-card">
|
<div class="gallery-card">
|
||||||
<div class="gallery-thumb">
|
<div class="gallery-thumb">
|
||||||
<?php if (!empty($photo['previewUrl'])): ?>
|
<?php if ($photoPreviewUrl !== ''): ?>
|
||||||
<img src="<?php echo faceai_sim_html($photo['previewUrl']); ?>" alt="<?php echo faceai_sim_html($photo['label']); ?>">
|
<img src="<?php echo faceai_sim_html($photoPreviewUrl); ?>" alt="<?php echo faceai_sim_html($photoLabel); ?>">
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?php echo faceai_sim_html($photo['thumb'] ?? $photo['id']); ?>
|
<?php echo faceai_sim_html($photoThumb); ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<strong><?php echo faceai_sim_html($photo['label'] ?? $photo['id']); ?></strong><br>
|
<strong><?php echo faceai_sim_html($photoLabel); ?></strong><br>
|
||||||
<small>ID foto: <?php echo faceai_sim_html($photo['id'] ?? ''); ?></small><br>
|
<small>ID foto: <?php echo faceai_sim_html($photoId); ?></small><br>
|
||||||
<small>Punto foto: <?php echo faceai_sim_html($photo['checkpoint'] ?? '-'); ?></small>
|
<small>Punto foto: <?php echo faceai_sim_html($photoCheckpoint); ?></small>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -169,14 +182,58 @@ window.faceAiSimulator = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
handoffUrl: 'faceai_handoff.php',
|
handoffUrl: 'faceai_handoff.php',
|
||||||
returnUrl: <?php echo json_encode($returnUrl); ?>,
|
returnUrl: <?php echo json_encode($returnUrl); ?>,
|
||||||
|
raceStorage: {
|
||||||
|
year: <?php echo json_encode($raceYear); ?>,
|
||||||
|
monthFolder: <?php echo json_encode($raceMonthFolder); ?>,
|
||||||
|
raceFolder: <?php echo json_encode($raceFolder); ?>
|
||||||
|
},
|
||||||
devUserId: '1',
|
devUserId: '1',
|
||||||
devDisplayName: 'Mario Rossi',
|
devDisplayName: 'Mario Rossi',
|
||||||
devEmail: 'mario.rossi@example.test',
|
devEmail: 'mario.rossi@example.test',
|
||||||
devMembershipStatus: 'active'
|
devMembershipStatus: 'active'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
var hostname = window.location && window.location.hostname ? window.location.hostname : '';
|
||||||
|
var isLocalDebug = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
|
||||||
|
if (!isLocalDebug || !window.console || typeof window.console.groupCollapsed !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.console.groupCollapsed('[FaceAI] Simulator bootstrap');
|
||||||
|
window.console.log({
|
||||||
|
pageUrl: window.location.href,
|
||||||
|
race: {
|
||||||
|
id: <?php echo json_encode($raceId); ?>,
|
||||||
|
slug: <?php echo json_encode($raceSlug); ?>,
|
||||||
|
name: <?php echo json_encode($raceName); ?>,
|
||||||
|
lang: <?php echo json_encode($lang); ?>,
|
||||||
|
storage: {
|
||||||
|
year: <?php echo json_encode($raceYear); ?>,
|
||||||
|
monthFolder: <?php echo json_encode($raceMonthFolder); ?>,
|
||||||
|
raceFolder: <?php echo json_encode($raceFolder); ?>,
|
||||||
|
relativeDir: <?php echo json_encode(implode('/', array_values(array_filter([$raceYear, $raceMonthFolder, $raceFolder], static function ($segment) {
|
||||||
|
return $segment !== null && $segment !== '';
|
||||||
|
})))); ?>
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handoff: {
|
||||||
|
url: 'faceai_handoff.php',
|
||||||
|
returnUrl: <?php echo json_encode($returnUrl); ?>
|
||||||
|
},
|
||||||
|
devUser: {
|
||||||
|
id: '1',
|
||||||
|
displayName: 'Mario Rossi',
|
||||||
|
email: 'mario.rossi@example.test',
|
||||||
|
membershipStatus: 'active'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.console.groupEnd();
|
||||||
|
}());
|
||||||
</script>
|
</script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<script src="vendor/jquery/jquery.min.js"></script>
|
<script src="vendor/jquery/jquery.min.js"></script>
|
||||||
|
<script src="vendor/popper/popper.min.js"></script>
|
||||||
<script src="vendor/bootstrap/js/bootstrap.min.js"></script>
|
<script src="vendor/bootstrap/js/bootstrap.min.js"></script>
|
||||||
<script src="_js/rus-ecom-240621.js"></script>
|
<script src="_js/rus-ecom-240621.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,50 @@ if (faceAiFeatureEnabledValue == null) {
|
||||||
faceAiFeatureEnabledValue = System.getProperty("FACEAI_FEATURE_ENABLED", "0");
|
faceAiFeatureEnabledValue = System.getProperty("FACEAI_FEATURE_ENABLED", "0");
|
||||||
}
|
}
|
||||||
boolean faceAiFeatureEnabled = "1".equals(faceAiFeatureEnabledValue) || "true".equalsIgnoreCase(faceAiFeatureEnabledValue) || "yes".equalsIgnoreCase(faceAiFeatureEnabledValue) || "on".equalsIgnoreCase(faceAiFeatureEnabledValue);
|
boolean faceAiFeatureEnabled = "1".equals(faceAiFeatureEnabledValue) || "true".equalsIgnoreCase(faceAiFeatureEnabledValue) || "yes".equalsIgnoreCase(faceAiFeatureEnabledValue) || "on".equalsIgnoreCase(faceAiFeatureEnabledValue);
|
||||||
|
java.util.Date faceAiRaceDate = CR.getGara().getDataGaraInizio();
|
||||||
|
String faceAiRacePathBase = CR.getGara().getPathBase() != null ? CR.getGara().getPathBase().trim() : "";
|
||||||
|
String faceAiRaceYear = "";
|
||||||
|
String faceAiRaceMonthFolder = "";
|
||||||
|
String faceAiRaceFolder = "";
|
||||||
|
String faceAiRaceStorageRelativeDir = "";
|
||||||
|
if (!faceAiRacePathBase.isEmpty()) {
|
||||||
|
String[] faceAiPathSegments = faceAiRacePathBase.split("/");
|
||||||
|
java.util.ArrayList faceAiNormalizedSegments = new java.util.ArrayList();
|
||||||
|
for (int faceAiSegmentIndex = 0; faceAiSegmentIndex < faceAiPathSegments.length; faceAiSegmentIndex++) {
|
||||||
|
String faceAiSegment = faceAiPathSegments[faceAiSegmentIndex] != null ? faceAiPathSegments[faceAiSegmentIndex].trim() : "";
|
||||||
|
if (!faceAiSegment.isEmpty()) {
|
||||||
|
faceAiNormalizedSegments.add(faceAiSegment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (faceAiNormalizedSegments.size() > 0) {
|
||||||
|
faceAiRaceYear = (String) faceAiNormalizedSegments.get(0);
|
||||||
|
}
|
||||||
|
if (faceAiNormalizedSegments.size() > 1) {
|
||||||
|
faceAiRaceMonthFolder = (String) faceAiNormalizedSegments.get(1);
|
||||||
|
}
|
||||||
|
if (faceAiNormalizedSegments.size() > 2) {
|
||||||
|
faceAiRaceFolder = (String) faceAiNormalizedSegments.get(2);
|
||||||
|
} else if (faceAiNormalizedSegments.size() > 1) {
|
||||||
|
faceAiRaceFolder = (String) faceAiNormalizedSegments.get(faceAiNormalizedSegments.size() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (faceAiRaceYear.isEmpty() && faceAiRaceDate != null) {
|
||||||
|
java.util.Calendar faceAiCalendar = java.util.Calendar.getInstance();
|
||||||
|
faceAiCalendar.setTime(faceAiRaceDate);
|
||||||
|
faceAiRaceYear = String.valueOf(faceAiCalendar.get(java.util.Calendar.YEAR));
|
||||||
|
}
|
||||||
|
if (faceAiRaceFolder.isEmpty()) {
|
||||||
|
faceAiRaceFolder = String.valueOf(CR.getGara().getId_gara());
|
||||||
|
}
|
||||||
|
if (!faceAiRaceYear.isEmpty()) {
|
||||||
|
faceAiRaceStorageRelativeDir = faceAiRaceYear;
|
||||||
|
if (!faceAiRaceMonthFolder.isEmpty()) {
|
||||||
|
faceAiRaceStorageRelativeDir += "/" + faceAiRaceMonthFolder;
|
||||||
|
}
|
||||||
|
if (!faceAiRaceFolder.isEmpty()) {
|
||||||
|
faceAiRaceStorageRelativeDir += "/" + faceAiRaceFolder;
|
||||||
|
}
|
||||||
|
}
|
||||||
%>
|
%>
|
||||||
<!-- InstanceEndEditable -->
|
<!-- InstanceEndEditable -->
|
||||||
<!-- InstanceBeginEditable name="doctitle" -->
|
<!-- InstanceBeginEditable name="doctitle" -->
|
||||||
|
|
@ -121,6 +165,11 @@ boolean faceAiFeatureEnabled = "1".equals(faceAiFeatureEnabledValue) || "true".e
|
||||||
<input name="id_gara" id="id_gara" type="hidden" value="<%= CR.getId_gara() %>">
|
<input name="id_gara" id="id_gara" type="hidden" value="<%= CR.getId_gara() %>">
|
||||||
<input name="id_foto" id="id_foto" type="hidden">
|
<input name="id_foto" id="id_foto" type="hidden">
|
||||||
<input name="garaDesc" id="garaDesc" type="hidden" value="<%=bean.getDescrizioneGaraHtml() %>">
|
<input name="garaDesc" id="garaDesc" type="hidden" value="<%=bean.getDescrizioneGaraHtml() %>">
|
||||||
|
<input name="faceAiRaceYear" id="faceAiRaceYear" type="hidden" value="<%= faceAiRaceYear %>">
|
||||||
|
<input name="faceAiRaceMonthFolder" id="faceAiRaceMonthFolder" type="hidden" value="<%= faceAiRaceMonthFolder %>">
|
||||||
|
<input name="faceAiRaceFolder" id="faceAiRaceFolder" type="hidden" value="<%= faceAiRaceFolder %>">
|
||||||
|
<input name="faceAiRacePathBase" id="faceAiRacePathBase" type="hidden" value="<%= faceAiRacePathBase %>">
|
||||||
|
<input name="faceAiRaceStorageRelativeDir" id="faceAiRaceStorageRelativeDir" type="hidden" value="<%= faceAiRaceStorageRelativeDir %>">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<input type="hidden" name="pageNumber" id="pageNumber" value="<%=list.getPageNumber()%>">
|
<input type="hidden" name="pageNumber" id="pageNumber" value="<%=list.getPageNumber()%>">
|
||||||
<input type="hidden" name="actionPage" id="actionPage" value="Foto.abl">
|
<input type="hidden" name="actionPage" id="actionPage" value="Foto.abl">
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,50 @@ if (faceAiFeatureEnabledValue == null) {
|
||||||
faceAiFeatureEnabledValue = System.getProperty("FACEAI_FEATURE_ENABLED", "0");
|
faceAiFeatureEnabledValue = System.getProperty("FACEAI_FEATURE_ENABLED", "0");
|
||||||
}
|
}
|
||||||
boolean faceAiFeatureEnabled = "1".equals(faceAiFeatureEnabledValue) || "true".equalsIgnoreCase(faceAiFeatureEnabledValue) || "yes".equalsIgnoreCase(faceAiFeatureEnabledValue) || "on".equalsIgnoreCase(faceAiFeatureEnabledValue);
|
boolean faceAiFeatureEnabled = "1".equals(faceAiFeatureEnabledValue) || "true".equalsIgnoreCase(faceAiFeatureEnabledValue) || "yes".equalsIgnoreCase(faceAiFeatureEnabledValue) || "on".equalsIgnoreCase(faceAiFeatureEnabledValue);
|
||||||
|
java.util.Date faceAiRaceDate = CR.getGara().getDataGaraInizio();
|
||||||
|
String faceAiRacePathBase = CR.getGara().getPathBase() != null ? CR.getGara().getPathBase().trim() : "";
|
||||||
|
String faceAiRaceYear = "";
|
||||||
|
String faceAiRaceMonthFolder = "";
|
||||||
|
String faceAiRaceFolder = "";
|
||||||
|
String faceAiRaceStorageRelativeDir = "";
|
||||||
|
if (!faceAiRacePathBase.isEmpty()) {
|
||||||
|
String[] faceAiPathSegments = faceAiRacePathBase.split("/");
|
||||||
|
java.util.ArrayList faceAiNormalizedSegments = new java.util.ArrayList();
|
||||||
|
for (int faceAiSegmentIndex = 0; faceAiSegmentIndex < faceAiPathSegments.length; faceAiSegmentIndex++) {
|
||||||
|
String faceAiSegment = faceAiPathSegments[faceAiSegmentIndex] != null ? faceAiPathSegments[faceAiSegmentIndex].trim() : "";
|
||||||
|
if (!faceAiSegment.isEmpty()) {
|
||||||
|
faceAiNormalizedSegments.add(faceAiSegment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (faceAiNormalizedSegments.size() > 0) {
|
||||||
|
faceAiRaceYear = (String) faceAiNormalizedSegments.get(0);
|
||||||
|
}
|
||||||
|
if (faceAiNormalizedSegments.size() > 1) {
|
||||||
|
faceAiRaceMonthFolder = (String) faceAiNormalizedSegments.get(1);
|
||||||
|
}
|
||||||
|
if (faceAiNormalizedSegments.size() > 2) {
|
||||||
|
faceAiRaceFolder = (String) faceAiNormalizedSegments.get(2);
|
||||||
|
} else if (faceAiNormalizedSegments.size() > 1) {
|
||||||
|
faceAiRaceFolder = (String) faceAiNormalizedSegments.get(faceAiNormalizedSegments.size() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (faceAiRaceYear.isEmpty() && faceAiRaceDate != null) {
|
||||||
|
java.util.Calendar faceAiCalendar = java.util.Calendar.getInstance();
|
||||||
|
faceAiCalendar.setTime(faceAiRaceDate);
|
||||||
|
faceAiRaceYear = String.valueOf(faceAiCalendar.get(java.util.Calendar.YEAR));
|
||||||
|
}
|
||||||
|
if (faceAiRaceFolder.isEmpty()) {
|
||||||
|
faceAiRaceFolder = String.valueOf(CR.getGara().getId_gara());
|
||||||
|
}
|
||||||
|
if (!faceAiRaceYear.isEmpty()) {
|
||||||
|
faceAiRaceStorageRelativeDir = faceAiRaceYear;
|
||||||
|
if (!faceAiRaceMonthFolder.isEmpty()) {
|
||||||
|
faceAiRaceStorageRelativeDir += "/" + faceAiRaceMonthFolder;
|
||||||
|
}
|
||||||
|
if (!faceAiRaceFolder.isEmpty()) {
|
||||||
|
faceAiRaceStorageRelativeDir += "/" + faceAiRaceFolder;
|
||||||
|
}
|
||||||
|
}
|
||||||
%>
|
%>
|
||||||
<!-- InstanceEndEditable -->
|
<!-- InstanceEndEditable -->
|
||||||
<!-- InstanceBeginEditable name="doctitle" -->
|
<!-- InstanceBeginEditable name="doctitle" -->
|
||||||
|
|
@ -121,6 +165,11 @@ boolean faceAiFeatureEnabled = "1".equals(faceAiFeatureEnabledValue) || "true".e
|
||||||
<input name="id_gara" id="id_gara" type="hidden" value="<%= CR.getId_gara() %>">
|
<input name="id_gara" id="id_gara" type="hidden" value="<%= CR.getId_gara() %>">
|
||||||
<input name="id_foto" id="id_foto" type="hidden">
|
<input name="id_foto" id="id_foto" type="hidden">
|
||||||
<input name="garaDesc" id="garaDesc" type="hidden" value="<%=bean.getDescrizioneGaraHtml() %>">
|
<input name="garaDesc" id="garaDesc" type="hidden" value="<%=bean.getDescrizioneGaraHtml() %>">
|
||||||
|
<input name="faceAiRaceYear" id="faceAiRaceYear" type="hidden" value="<%= faceAiRaceYear %>">
|
||||||
|
<input name="faceAiRaceMonthFolder" id="faceAiRaceMonthFolder" type="hidden" value="<%= faceAiRaceMonthFolder %>">
|
||||||
|
<input name="faceAiRaceFolder" id="faceAiRaceFolder" type="hidden" value="<%= faceAiRaceFolder %>">
|
||||||
|
<input name="faceAiRacePathBase" id="faceAiRacePathBase" type="hidden" value="<%= faceAiRacePathBase %>">
|
||||||
|
<input name="faceAiRaceStorageRelativeDir" id="faceAiRaceStorageRelativeDir" type="hidden" value="<%= faceAiRaceStorageRelativeDir %>">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<input type="hidden" name="pageNumber" id="pageNumber" value="<%=list.getPageNumber()%>">
|
<input type="hidden" name="pageNumber" id="pageNumber" value="<%=list.getPageNumber()%>">
|
||||||
<input type="hidden" name="actionPage" id="actionPage" value="Foto.abl">
|
<input type="hidden" name="actionPage" id="actionPage" value="Foto.abl">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue